Expose the watch subcommand

`watch::watch` has been written, tested and unreachable from the CLI since it
landed. It now has a command: `cosmic-conf watch [--config <path>]`, sharing
`--config` with `apply` and refusing `--diff`, which means nothing for a
daemon whose whole job is to notice a change and write it.

Exposing it made an existing wart user-visible: a single bad save reported
itself three or four times. One write arrives as several inotify events --
modify, close_write, and a rename when the editor writes atomically -- and
they do not all land inside one 250ms debounce window, so each produced its
own compile and its own copy of the same diagnostic. Consecutive identical
errors are now printed once, reset on any successful compile so the same
error after a good one is still news.

Verified against an isolated XDG_CONFIG_HOME, driving a real daemon rather
than calling `compile` directly, since none of this is reachable from the unit
tests: applies at startup, recompiles on edit, notices edits to sourced files,
picks up a `source` line added at runtime, survives a malformed edit with the
last good value intact, reports it exactly once, does not suppress a
*different* error, and resumes after a fix. Ten checks, all passing.
This commit is contained in:
2026-08-10 09:42:51 +07:00
parent cd99893b33
commit 9933ff2415
2 changed files with 58 additions and 5 deletions
+40 -3
View File
@@ -12,9 +12,17 @@ cosmic-conf — compile cosmic.conf into the cosmic-config tree
USAGE: USAGE:
cosmic-conf apply [--diff] [--config <path>] cosmic-conf apply [--diff] [--config <path>]
cosmic-conf watch [--config <path>]
cosmic-conf import-theme <hypr.theme> [--out <path>] [--report] cosmic-conf import-theme <hypr.theme> [--out <path>] [--report]
[--assets [--source <dir>] [--overwrite] [--dry-run]] [--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: OPTIONS:
--diff Show what would change without writing anything --diff Show what would change without writing anything
--config <path> Config file (default: $XDG_CONFIG_HOME/hyprcosmic/cosmic.conf) --config <path> Config file (default: $XDG_CONFIG_HOME/hyprcosmic/cosmic.conf)
@@ -99,12 +107,18 @@ fn main() -> ExitCode {
} }
}; };
} }
if args[0] != "apply" { let command = args[0].as_str();
eprintln!("error: unknown command `{}`\n\n{USAGE}", args[0]); if !matches!(command, "apply" | "watch") {
eprintln!("error: unknown command `{command}`\n\n{USAGE}");
return ExitCode::from(2); return ExitCode::from(2);
} }
if let Err(msg) = reject_unknown(&args[1..], &["--diff"], &["--config"]) { // `--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}"); eprintln!("{msg}\n\n{USAGE}");
return ExitCode::from(2); return ExitCode::from(2);
} }
@@ -127,6 +141,16 @@ fn main() -> ExitCode {
}, },
}; };
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) { match run(&config_path, diff_only) {
Ok(msg) => { Ok(msg) => {
println!("{msg}"); println!("{msg}");
@@ -184,6 +208,19 @@ fn run(config_path: &Path, diff_only: bool) -> Result<String, String> {
)) ))
} }
/// 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> { fn run_import(args: &[String]) -> Result<String, String> {
let Some(src_path) = args.first().filter(|a| !a.starts_with("--")) else { let Some(src_path) = args.first().filter(|a| !a.starts_with("--")) else {
return Err(format!("error: import-theme needs a path\n\n{USAGE}")); return Err(format!("error: import-theme needs a path\n\n{USAGE}"));
+18 -2
View File
@@ -344,6 +344,15 @@ pub fn watch(config: &Path, emitter: &Emitter) -> Result<(), WatchError> {
let mut watcher: RecommendedWatcher = notify::recommended_watcher(tx)?; let mut watcher: RecommendedWatcher = notify::recommended_watcher(tx)?;
let mut watched: HashSet<PathBuf> = HashSet::new(); let mut watched: HashSet<PathBuf> = HashSet::new();
// The last diagnostic printed, so an unchanged one is not printed again.
// A single save arrives as several inotify events -- modify, then
// close_write, sometimes a rename when the editor writes atomically -- and
// they do not all land inside one debounce window, so a broken config
// otherwise reports itself three or four times per keystroke-save. Cleared
// on every successful compile, so the same error reappearing after a good
// one is still news and still printed.
let mut last_error: Option<String> = None;
// Compile once up front: the desktop should reflect the config the // Compile once up front: the desktop should reflect the config the
// moment the daemon starts, and this also tells us the initial watch // moment the daemon starts, and this also tells us the initial watch
// set. If it fails, fall back to watching just `config` — that is the // set. If it fails, fall back to watching just `config` — that is the
@@ -357,7 +366,9 @@ pub fn watch(config: &Path, emitter: &Emitter) -> Result<(), WatchError> {
sync_watches(&mut watcher, &mut watched, &compiled.sources); sync_watches(&mut watcher, &mut watched, &compiled.sources);
} }
Err(e) => { Err(e) => {
eprintln!("{e}"); let text = e.to_string();
eprintln!("{text}");
last_error = Some(text);
sync_watches( sync_watches(
&mut watcher, &mut watcher,
&mut watched, &mut watched,
@@ -381,6 +392,7 @@ pub fn watch(config: &Path, emitter: &Emitter) -> Result<(), WatchError> {
match compile(config, emitter) { match compile(config, emitter) {
Ok(compiled) => { Ok(compiled) => {
last_error = None;
if let Err(e) = emitter.apply(&compiled.planned) { if let Err(e) = emitter.apply(&compiled.planned) {
eprintln!("{}", CompileError::Emit(vec![e])); eprintln!("{}", CompileError::Emit(vec![e]));
} }
@@ -390,7 +402,11 @@ pub fn watch(config: &Path, emitter: &Emitter) -> Result<(), WatchError> {
// Leave `watched` alone: the fix for a bad edit might land in // Leave `watched` alone: the fix for a bad edit might land in
// an already-sourced file, and dropping back to watching // an already-sourced file, and dropping back to watching
// only `config` would miss that. // only `config` would miss that.
eprintln!("{e}"); let text = e.to_string();
if last_error.as_deref() != Some(text.as_str()) {
eprintln!("{text}");
last_error = Some(text);
}
} }
} }
} }