mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 14:53:21 +00:00
The four-layer rofi chain needs config.rasi and local.rasi to exist, and neither has an upstream file to copy: a HyDE theme has no equivalent of either. They were hand-written for this machine, which meant a second machine got a launcher that reported a missing @import instead of opening. config.rasi is `include_str!`d from config/rofi/ rather than kept as a string literal, so it stays a real .rasi file -- highlightable, diffable, editable without a rebuild to see the result -- and there is one copy of it rather than two that can disagree. local.rasi is composed per machine from two things the repo cannot know: the theme's $ICON_THEME, and a wallpaper path. Each half is omitted entirely when there is nothing to say, because an empty `icon-theme:` list is something rofi would honour. $ICON_THEME comes back as a field on Import rather than being re-parsed out of the conf text that the same function just rendered. The wallpaper needed a stable name. local.rasi cannot hardcode a filename without going stale at the next theme import, so plan_wallpapers now also maintains ~/.local/share/wallpapers/hyprcosmic/current as a symlink to one of the copies it made. HyDE has this problem too and solves it the same way, with ~/.cache/hyde/wall.thmb. The launcher sidebar and the autostart's `awww img` line both name the link, so they cannot drift apart. Which wallpaper it points at is the first in *sorted* order. read_dir returns whatever the filesystem feels like, and an arbitrary choice is fine where an unrepeatable one is not: re-running the import would otherwise change the wallpaper at random. The link is repointed even when every wallpaper was skipped as already installed -- the copies are theme-specific and unchanged, but the link is global and has to follow the theme just imported. Repointing has to handle a *dangling* link, which is exactly what a previous import leaves behind once its theme directory is gone: symlink(2) fails with EEXIST rather than replacing, and Path::exists follows the link, so it answers false for the one case that needs removing. symlink_metadata asks about the link itself. Values reaching a generated config come from a theme directory that may have been downloaded from anywhere, so quote_rasi_string strips quotes, backslashes and control characters -- .rasi has no escape syntax worth relying on. Four existing tests counted actions and broke once every plan carried two more. Fixed with a theme_assets() filter rather than by bumping the numbers, so what they are actually asserting stays visible and the next generated file does not break them again. 125 unit, 5 bin and 4 integration tests pass; clippy --all-targets and `cargo fmt --check` are clean. A real import of Tokyo Night installed all 13 paths, and `cosmic-conf apply --diff` reports no changes afterwards.
433 lines
16 KiB
Rust
433 lines
16 KiB
Rust
//! `cosmic-conf` — compile a Hyprland-idiom config file into cosmic-config.
|
|
//!
|
|
//! Exit codes: 0 success, 1 config error (nothing written), 2 usage error.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::ExitCode;
|
|
|
|
use cosmic_conf::{assets, emit::Emitter, import, render_diagnostic, watch};
|
|
|
|
const USAGE: &str = "\
|
|
cosmic-conf — compile cosmic.conf into the cosmic-config tree
|
|
|
|
USAGE:
|
|
cosmic-conf apply [--diff] [--config <path>]
|
|
cosmic-conf watch [--config <path>]
|
|
cosmic-conf import-theme <hypr.theme> [--out <path>] [--report]
|
|
[--assets [--source <dir>] [--overwrite] [--dry-run]]
|
|
|
|
COMMANDS:
|
|
apply Compile the config once and exit
|
|
watch Stay running and recompile on every edit, to the config
|
|
and to anything it sources. A malformed edit is reported
|
|
and waited past, not fatal.
|
|
import-theme Translate a HyDE theme into config keys
|
|
|
|
OPTIONS:
|
|
--diff Show what would change without writing anything
|
|
--config <path> Config file (default: $XDG_CONFIG_HOME/hyprcosmic/cosmic.conf)
|
|
--out <path> Write the generated cosmic.conf here (default: stdout)
|
|
--report Print everything that did not translate cleanly
|
|
--assets Also install wallpapers, GTK/icon themes and the
|
|
waybar/rofi/kitty theme files that sit beside hypr.theme
|
|
--source <dir> The theme repo's Source/ directory holding the GTK and
|
|
icon tarballs (default: found by searching upward)
|
|
--overwrite Replace assets that are already installed
|
|
--dry-run With --assets, list what would be installed and stop
|
|
-h, --help Show this help
|
|
";
|
|
|
|
/// HyDE keeps GTK and icon tarballs in a `Source/` directory at the root of
|
|
/// the theme repo, four levels above the theme folder
|
|
/// (`Configs/.config/hyde/themes/<Name>/`). Searching upward rather than
|
|
/// hardcoding that depth means a theme unpacked at a different depth, or one
|
|
/// vendored into another tree, still works.
|
|
fn find_source_dir(theme_dir: &std::path::Path) -> Option<PathBuf> {
|
|
theme_dir
|
|
.ancestors()
|
|
.take(6)
|
|
.map(|a| a.join("Source"))
|
|
.find(|c| c.is_dir())
|
|
}
|
|
|
|
/// Refuse arguments the caller does not understand.
|
|
///
|
|
/// `apply` writes to the config tree, so an argument it does not recognise has
|
|
/// to stop it rather than be skipped: `--diff-only` instead of `--diff`, or a
|
|
/// config path given positionally, would otherwise apply to the *default*
|
|
/// config while looking like it had done what was asked. This is a
|
|
/// hand-rolled check rather than a dependency because the whole surface is six
|
|
/// flags, and an argument parser that silently ignores the unknown is exactly
|
|
/// the behaviour being removed.
|
|
///
|
|
/// `flags` take no value; `valued` consume the argument after them.
|
|
fn reject_unknown(args: &[String], flags: &[&str], valued: &[&str]) -> Result<(), String> {
|
|
let mut i = 0;
|
|
while i < args.len() {
|
|
let a = &args[i];
|
|
if valued.contains(&a.as_str()) {
|
|
i += 2;
|
|
} else if flags.contains(&a.as_str()) || a == "-h" || a == "--help" {
|
|
// `--help` is accepted by every subcommand, so callers do not have
|
|
// to list it; `main` has already acted on it by this point.
|
|
i += 1;
|
|
} else if let Some(name) = a.strip_prefix("--") {
|
|
return Err(format!("error: unknown option `--{name}`"));
|
|
} else {
|
|
return Err(format!("error: unexpected argument `{a}`"));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn default_config_path() -> Option<PathBuf> {
|
|
let base = match std::env::var_os("XDG_CONFIG_HOME") {
|
|
Some(x) if !x.is_empty() => PathBuf::from(x),
|
|
_ => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
|
|
};
|
|
Some(base.join("hyprcosmic").join("cosmic.conf"))
|
|
}
|
|
|
|
fn main() -> ExitCode {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
|
|
if args.is_empty() || args.iter().any(|a| a == "-h" || a == "--help") {
|
|
print!("{USAGE}");
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
if args[0] == "import-theme" {
|
|
return match run_import(&args[1..]) {
|
|
Ok(msg) => {
|
|
print!("{msg}");
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(msg) => {
|
|
eprint!("{msg}");
|
|
ExitCode::from(1)
|
|
}
|
|
};
|
|
}
|
|
let command = args[0].as_str();
|
|
if !matches!(command, "apply" | "watch") {
|
|
eprintln!("error: unknown command `{command}`\n\n{USAGE}");
|
|
return ExitCode::from(2);
|
|
}
|
|
|
|
// `--diff` belongs to `apply` alone: a daemon whose whole job is to notice
|
|
// a change and write it has nothing to do with a mode that declines to
|
|
// write. Passing it to `watch` is an error rather than a no-op, for the
|
|
// same reason `--diff-only` is.
|
|
let flags: &[&str] = if command == "apply" { &["--diff"] } else { &[] };
|
|
if let Err(msg) = reject_unknown(&args[1..], flags, &["--config"]) {
|
|
eprintln!("{msg}\n\n{USAGE}");
|
|
return ExitCode::from(2);
|
|
}
|
|
|
|
let diff_only = args.iter().any(|a| a == "--diff");
|
|
let config_path = match args.iter().position(|a| a == "--config") {
|
|
Some(i) => match args.get(i + 1) {
|
|
Some(p) => PathBuf::from(p),
|
|
None => {
|
|
eprintln!("error: --config needs a path");
|
|
return ExitCode::from(2);
|
|
}
|
|
},
|
|
None => match default_config_path() {
|
|
Some(p) => p,
|
|
None => {
|
|
eprintln!("error: cannot determine config path (no HOME or XDG_CONFIG_HOME)");
|
|
return ExitCode::from(2);
|
|
}
|
|
},
|
|
};
|
|
|
|
if command == "watch" {
|
|
return match run_watch(&config_path) {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(msg) => {
|
|
eprint!("{msg}");
|
|
ExitCode::from(1)
|
|
}
|
|
};
|
|
}
|
|
|
|
match run(&config_path, diff_only) {
|
|
Ok(msg) => {
|
|
println!("{msg}");
|
|
ExitCode::SUCCESS
|
|
}
|
|
Err(msg) => {
|
|
eprint!("{msg}");
|
|
ExitCode::from(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run(config_path: &Path, diff_only: bool) -> Result<String, String> {
|
|
let emitter = Emitter::from_env().map_err(|e| format!("error: {e}\n"))?;
|
|
|
|
// Through `watch::compile` rather than parse/resolve/plan inline, because
|
|
// that is the only path that expands `source`. Doing it by hand here meant
|
|
// `resolve` never saw the included text -- `flatten` drops `Item::Source`
|
|
// -- so a sourced file was silently ignored by `apply` while `watch`
|
|
// honoured it. An include that works in one and vanishes in the other is
|
|
// worse than one that is unsupported in both.
|
|
let compiled = watch::compile(config_path, &emitter).map_err(|e| e.to_string())?;
|
|
let planned = compiled.planned;
|
|
|
|
let changes: Vec<_> = planned.iter().filter(|p| !p.is_noop()).collect();
|
|
|
|
if diff_only {
|
|
if changes.is_empty() {
|
|
return Ok("No changes.".into());
|
|
}
|
|
let mut out = String::new();
|
|
for p in &changes {
|
|
let rel = p
|
|
.path
|
|
.strip_prefix(emitter.root())
|
|
.unwrap_or(&p.path)
|
|
.display();
|
|
out.push_str(&format!("~ {rel}\n"));
|
|
match &p.previous {
|
|
Some(prev) => out.push_str(&format!(" - {}\n", prev.trim())),
|
|
None => out.push_str(" - (unset)\n"),
|
|
}
|
|
out.push_str(&format!(" + {}\n", p.contents.trim()));
|
|
}
|
|
out.push_str(&format!("\n{} file(s) would change.", changes.len()));
|
|
return Ok(out);
|
|
}
|
|
|
|
let written = emitter
|
|
.apply(&planned)
|
|
.map_err(|e| format!("error: {e}\n"))?;
|
|
Ok(format!(
|
|
"Applied {written} change(s) to {}.",
|
|
emitter.root().display()
|
|
))
|
|
}
|
|
|
|
/// Block, recompiling on every edit, until the watcher itself stops.
|
|
///
|
|
/// Returns nothing to print on success because there is no success to report
|
|
/// until it is over: progress goes to stderr as it happens, from inside the
|
|
/// loop. A config error is not an error here either -- `watch` reports a
|
|
/// malformed edit and waits for the next one, which is the whole point of
|
|
/// leaving it running -- so the only failure that reaches this function is the
|
|
/// notify machinery failing to start.
|
|
fn run_watch(config_path: &Path) -> Result<(), String> {
|
|
let emitter = Emitter::from_env().map_err(|e| format!("error: {e}\n"))?;
|
|
watch::watch(config_path, &emitter).map_err(|e| format!("{e}\n"))
|
|
}
|
|
|
|
fn run_import(args: &[String]) -> Result<String, String> {
|
|
let Some(src_path) = args.first().filter(|a| !a.starts_with("--")) else {
|
|
return Err(format!("error: import-theme needs a path\n\n{USAGE}"));
|
|
};
|
|
reject_unknown(
|
|
&args[1..],
|
|
&["--report", "--assets", "--overwrite", "--dry-run"],
|
|
&["--out", "--source"],
|
|
)
|
|
.map_err(|msg| format!("{msg}\n\n{USAGE}"))?;
|
|
let out_path = args
|
|
.iter()
|
|
.position(|a| a == "--out")
|
|
.and_then(|i| args.get(i + 1))
|
|
.map(PathBuf::from);
|
|
let want_report = args.iter().any(|a| a == "--report");
|
|
|
|
let source = std::fs::read_to_string(src_path)
|
|
.map_err(|e| format!("error: cannot read {src_path}: {e}\n"))?;
|
|
|
|
// HyDE names a theme by its containing directory.
|
|
let name = PathBuf::from(src_path)
|
|
.parent()
|
|
.and_then(|p| p.file_name())
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "imported".into());
|
|
|
|
let imported = import::import_hypr_theme(&source, &name)
|
|
.map_err(|e| render_diagnostic(&source, e.span, &e.message, None))?;
|
|
|
|
let mut out = String::new();
|
|
match out_path {
|
|
Some(p) => {
|
|
if let Some(dir) = p.parent() {
|
|
std::fs::create_dir_all(dir)
|
|
.map_err(|e| format!("error: cannot create {}: {e}\n", dir.display()))?;
|
|
}
|
|
std::fs::write(&p, &imported.conf)
|
|
.map_err(|e| format!("error: cannot write {}: {e}\n", p.display()))?;
|
|
out.push_str(&format!("Wrote {}\n", p.display()));
|
|
if let Some(hint) = unsourced_hint(&p) {
|
|
out.push_str(&hint);
|
|
}
|
|
}
|
|
None => out.push_str(&imported.conf),
|
|
}
|
|
|
|
let dropped = imported.dropped().count();
|
|
if want_report {
|
|
out.push('\n');
|
|
out.push_str(&import::render_report(&imported));
|
|
} else if dropped > 0 {
|
|
out.push_str(&format!(
|
|
"\n{dropped} setting(s) did not translate. Re-run with --report for details.\n"
|
|
));
|
|
}
|
|
|
|
if args.iter().any(|a| a == "--assets") {
|
|
out.push('\n');
|
|
out.push_str(&install_assets(
|
|
src_path,
|
|
&name,
|
|
imported.icon_theme.as_deref(),
|
|
args,
|
|
)?);
|
|
}
|
|
|
|
Ok(out)
|
|
}
|
|
|
|
/// Warn when the file just written is not reachable from its sibling
|
|
/// cosmic.conf, and say what to add.
|
|
///
|
|
/// A theme lives in its own file so that re-importing cannot clobber the
|
|
/// keybindings around it, but that only works if something sources it.
|
|
/// Writing an inert file and reporting success is the worst of both: the tool
|
|
/// looks like it worked and the desktop does not change.
|
|
///
|
|
/// The match is textual and deliberately loose -- it is looking for evidence
|
|
/// that the user already knows about the file, not parsing the config. A false
|
|
/// negative costs one redundant hint; a false positive would hide a real
|
|
/// problem, so the substring searched for is the filename itself.
|
|
fn unsourced_hint(written: &Path) -> Option<String> {
|
|
let dir = written.parent()?;
|
|
let name = written.file_name()?.to_string_lossy().to_string();
|
|
let main = dir.join("cosmic.conf");
|
|
|
|
// Nothing to say when the theme *is* the config, or there is no config yet
|
|
// to add a line to: `apply` will be pointed at this file directly.
|
|
if main == written || !main.exists() {
|
|
return None;
|
|
}
|
|
|
|
let text = std::fs::read_to_string(&main).ok()?;
|
|
if text
|
|
.lines()
|
|
.any(|l| l.trim_start().starts_with("source") && l.contains(&name))
|
|
{
|
|
return None;
|
|
}
|
|
|
|
Some(format!(
|
|
"\nNothing sources it yet, so `apply` will ignore it. Add this to {}:\n\n source = {}\n",
|
|
main.display(),
|
|
written.display(),
|
|
))
|
|
}
|
|
|
|
/// The half of a theme that is not config: wallpapers, GTK/icon tarballs, the
|
|
/// `.theme` files belonging to waybar, rofi and kitty, and the small rofi
|
|
/// files HyprCosmic has to compose itself.
|
|
///
|
|
/// Separate from the conf translation because it is separate in kind — almost
|
|
/// none of it is translated, only placed — and because it writes outside the
|
|
/// cosmic-config tree, which every other path in this tool does not.
|
|
///
|
|
/// `icon_theme` comes back out of the conf translation rather than being read
|
|
/// from the theme directory again, because that is where the `$ICON_THEME`
|
|
/// variable was already resolved.
|
|
fn install_assets(
|
|
src_path: &str,
|
|
name: &str,
|
|
icon_theme: Option<&str>,
|
|
args: &[String],
|
|
) -> Result<String, String> {
|
|
let theme_dir = PathBuf::from(src_path)
|
|
.parent()
|
|
.map(Path::to_path_buf)
|
|
.ok_or_else(|| format!("error: {src_path} has no parent directory\n"))?;
|
|
|
|
let source_dir = match args.iter().position(|a| a == "--source") {
|
|
Some(i) => match args.get(i + 1) {
|
|
Some(p) => Some(PathBuf::from(p)),
|
|
None => return Err(format!("error: --source needs a path\n\n{USAGE}")),
|
|
},
|
|
None => find_source_dir(&theme_dir),
|
|
};
|
|
|
|
let installer = assets::Installer::from_env().map_err(|e| format!("error: {e}\n"))?;
|
|
let plan = installer
|
|
.plan(
|
|
&theme_dir,
|
|
source_dir.as_deref(),
|
|
name,
|
|
icon_theme,
|
|
args.iter().any(|a| a == "--overwrite"),
|
|
)
|
|
.map_err(|errors| {
|
|
errors
|
|
.iter()
|
|
.map(|e| format!("error: {e}\n"))
|
|
.collect::<String>()
|
|
})?;
|
|
|
|
if args.iter().any(|a| a == "--dry-run") {
|
|
return Ok(assets::render_plan(&plan));
|
|
}
|
|
|
|
let report = installer
|
|
.apply(&plan)
|
|
.map_err(|e| format!("error: {e}\n"))?;
|
|
Ok(assets::render_report(&plan, &report))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn args(s: &[&str]) -> Vec<String> {
|
|
s.iter().map(|a| a.to_string()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn known_flags_and_their_values_are_accepted() {
|
|
let a = args(&["--diff", "--config", "/etc/cosmic.conf"]);
|
|
assert!(reject_unknown(&a, &["--diff"], &["--config"]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn a_misspelt_flag_is_refused_rather_than_skipped() {
|
|
// The bug this exists to prevent: `--diff-only` used to be ignored, so
|
|
// `apply` wrote for real while the caller believed it was a dry run.
|
|
let a = args(&["--diff-only"]);
|
|
let err = reject_unknown(&a, &["--diff"], &["--config"]).unwrap_err();
|
|
assert!(err.contains("--diff-only"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_positional_path_is_refused_because_it_would_be_ignored() {
|
|
let a = args(&["/home/me/.config/hyprcosmic/cosmic.conf"]);
|
|
let err = reject_unknown(&a, &["--diff"], &["--config"]).unwrap_err();
|
|
assert!(err.contains("unexpected argument"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_value_that_looks_like_a_flag_is_still_a_value() {
|
|
// `--config --diff` is a user error, but it is the *next* argument's
|
|
// job to be a path; consuming it here keeps the rule simple and
|
|
// matches what the position-based lookup below actually does.
|
|
let a = args(&["--config", "--diff"]);
|
|
assert!(reject_unknown(&a, &["--diff"], &["--config"]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn a_trailing_valued_flag_with_no_value_is_not_a_panic() {
|
|
let a = args(&["--config"]);
|
|
assert!(reject_unknown(&a, &["--diff"], &["--config"]).is_ok());
|
|
}
|
|
}
|