From dee24ac30a20b9498a8fa02e589c3c778dc56106 Mon Sep 17 00:00:00 2001 From: dingo Date: Sun, 9 Aug 2026 22:31:40 +0700 Subject: [PATCH] cosmic-conf: emit + CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emit writes RON directly rather than linking libcosmic. Spike 2 showed cosmic-config is a filesystem KV store whose notify watcher keys off file paths (lib.rs:377), so an atomic write is observed identically to the typed API — for the cost of ron instead of the whole libcosmic graph. Two-stage: plan renders without touching disk, apply writes. Composites are read-modify-write against verified upstream defaults — gaps (0,8) at theme.rs:939, CornerRadii at corner.rs:20-31 — so setting one field never drops its siblings. Unmodelled composites error rather than write blind. 52 tests. CLI verified end-to-end: apply, --diff (writes nothing), idempotent rerun, partial update preserving siblings, and multi-diagnostic failure with exit 1 and zero writes. --- cosmic-conf/.cargo/config.toml | 4 + cosmic-conf/Cargo.lock | 197 +++++++++++ cosmic-conf/Cargo.toml | 5 + cosmic-conf/src/emit.rs | 627 +++++++++++++++++++++++++++++++++ cosmic-conf/src/lib.rs | 2 + cosmic-conf/src/main.rs | 132 +++++++ cosmic-conf/src/resolve.rs | 81 +++-- cosmic-conf/src/schema.rs | 31 +- 8 files changed, 1041 insertions(+), 38 deletions(-) create mode 100644 cosmic-conf/.cargo/config.toml create mode 100644 cosmic-conf/src/emit.rs create mode 100644 cosmic-conf/src/main.rs diff --git a/cosmic-conf/.cargo/config.toml b/cosmic-conf/.cargo/config.toml new file mode 100644 index 0000000..19fa13a --- /dev/null +++ b/cosmic-conf/.cargo/config.toml @@ -0,0 +1,4 @@ +# Single-core builds: this crate is developed on a machine where parallel +# rustc jobs are not wanted. +[build] +jobs = 1 diff --git a/cosmic-conf/Cargo.lock b/cosmic-conf/Cargo.lock index 6779b99..f963fd5 100644 --- a/cosmic-conf/Cargo.lock +++ b/cosmic-conf/Cargo.lock @@ -2,6 +2,203 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "cosmic-conf" version = "0.1.0" +dependencies = [ + "ron", + "serde", + "tempfile", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/cosmic-conf/Cargo.toml b/cosmic-conf/Cargo.toml index 0332947..8513875 100644 --- a/cosmic-conf/Cargo.toml +++ b/cosmic-conf/Cargo.toml @@ -6,6 +6,8 @@ license = "GPL-3.0-only" description = "Compiles a single Hyprland-idiom config file into the cosmic-config tree" [dependencies] +ron = "0.12" +serde = { version = "1.0.229", features = ["derive"] } [features] # `emit` links cosmic-config and the component crates. Off by default so the @@ -13,3 +15,6 @@ description = "Compiles a single Hyprland-idiom config file into the cosmic-conf # dependency graph. default = [] emit = [] + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/cosmic-conf/src/emit.rs b/cosmic-conf/src/emit.rs new file mode 100644 index 0000000..244ada2 --- /dev/null +++ b/cosmic-conf/src/emit.rs @@ -0,0 +1,627 @@ +//! Resolved writes -> the cosmic-config tree. +//! +//! Spike 2 established the mechanism (see the spec's verified-findings table): +//! cosmic-config is a filesystem key-value store at +//! `$XDG_CONFIG_HOME/cosmic//v/`, each file holding one RON +//! literal. `Config::watch` (`cosmic-config/src/lib.rs:377`) is a `notify` +//! inotify watch on that directory which derives changed keys from file paths, +//! so a plain atomic write is observed exactly like a write from the typed API. +//! That is why this module needs `ron` rather than the whole libcosmic graph. +//! +//! Emission is two-stage on purpose. `plan` reads current state and renders +//! every file's new contents without touching disk; `apply` then writes. A +//! failure while planning therefore leaves the desktop untouched, preserving +//! the transactional guarantee `resolve` starts. + +use std::collections::BTreeMap; +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::resolve::{Resolved, TargetKey, Value, Write, WriteKind}; + +/// Mirror of `cosmic_theme::CornerRadii` (`cosmic-theme/src/model/corner.rs:5`). +/// +/// Duplicated rather than depended upon so this crate stays free of the +/// libcosmic build graph. The field set and defaults are pinned by tests; if +/// upstream adds a radius, round-tripping would silently drop it, so +/// `deny_unknown_fields` turns that into a loud parse error instead. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct CornerRadii { + radius_0: [f32; 4], + radius_xs: [f32; 4], + radius_s: [f32; 4], + radius_m: [f32; 4], + radius_l: [f32; 4], + radius_xl: [f32; 4], +} + +impl Default for CornerRadii { + /// `corner.rs:20-31`. + fn default() -> Self { + Self { + radius_0: [0.0; 4], + radius_xs: [4.0; 4], + radius_s: [8.0; 4], + radius_m: [16.0; 4], + radius_l: [32.0; 4], + radius_xl: [160.0; 4], + } + } +} + +impl CornerRadii { + fn field_mut(&mut self, name: &str) -> Option<&mut [f32; 4]> { + Some(match name { + "radius_0" => &mut self.radius_0, + "radius_xs" => &mut self.radius_xs, + "radius_s" => &mut self.radius_s, + "radius_m" => &mut self.radius_m, + "radius_l" => &mut self.radius_l, + "radius_xl" => &mut self.radius_xl, + _ => return None, + }) + } +} + +#[derive(Debug)] +pub enum EmitError { + Io(io::Error), + /// A projected target whose composite shape this emitter cannot rebuild. + UnsupportedComposite { key: String, detail: String }, + /// An existing file could not be parsed, so read-modify-write is unsafe. + Unreadable { path: PathBuf, detail: String }, + NoConfigDirectory, +} + +impl fmt::Display for EmitError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EmitError::Io(e) => write!(f, "io error: {e}"), + EmitError::UnsupportedComposite { key, detail } => { + write!(f, "cannot write `{key}`: {detail}") + } + EmitError::Unreadable { path, detail } => { + write!(f, "cannot parse existing `{}`: {detail}", path.display()) + } + EmitError::NoConfigDirectory => write!(f, "no config directory available"), + } + } +} + +impl std::error::Error for EmitError {} + +impl From for EmitError { + fn from(e: io::Error) -> Self { + EmitError::Io(e) + } +} + +/// One file's worth of pending change. `previous` powers `apply --diff`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Planned { + pub path: PathBuf, + pub contents: String, + pub previous: Option, +} + +impl Planned { + /// A write that would not change anything on disk. + pub fn is_noop(&self) -> bool { + self.previous.as_deref() == Some(self.contents.as_str()) + } +} + +pub struct Emitter { + root: PathBuf, +} + +impl Emitter { + /// Locate the cosmic-config root the same way cosmic-config does: + /// `$XDG_CONFIG_HOME/cosmic`, falling back to `$HOME/.config/cosmic`. + pub fn from_env() -> Result { + let base = match std::env::var_os("XDG_CONFIG_HOME") { + Some(x) if !x.is_empty() => PathBuf::from(x), + _ => { + let home = std::env::var_os("HOME").ok_or(EmitError::NoConfigDirectory)?; + PathBuf::from(home).join(".config") + } + }; + Ok(Self { + root: base.join("cosmic"), + }) + } + + pub fn with_root(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + fn path_for(&self, target: &TargetKey) -> PathBuf { + self.root + .join(&target.component) + .join(format!("v{}", target.version)) + .join(&target.key) + } + + /// Render every write without touching disk. + /// + /// Returns all errors rather than the first, matching `resolve`'s behaviour + /// so a user sees the whole picture in one pass. + pub fn plan(&self, resolved: &Resolved) -> Result, Vec> { + let mut planned = Vec::new(); + let mut errors = Vec::new(); + + for write in &resolved.writes { + match self.plan_one(write) { + Ok(p) => planned.push(p), + Err(e) => errors.push(e), + } + } + + if errors.is_empty() { + Ok(planned) + } else { + Err(errors) + } + } + + fn plan_one(&self, write: &Write) -> Result { + let path = self.path_for(&write.target); + let previous = match fs::read_to_string(&path) { + Ok(s) => Some(s), + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => return Err(e.into()), + }; + + let contents = match &write.kind { + WriteKind::Whole(v) => render(v), + WriteKind::Projected(fields) => { + composite(&write.target, fields, previous.as_deref(), &path)? + } + }; + + Ok(Planned { + path, + contents, + previous, + }) + } + + /// Write the plan. Callers should `plan` first so that failures surface + /// before any file is touched. + pub fn apply(&self, planned: &[Planned]) -> Result { + let mut written = 0; + for p in planned { + if p.is_noop() { + continue; + } + if let Some(dir) = p.path.parent() { + fs::create_dir_all(dir)?; + } + atomic_write(&p.path, &p.contents)?; + written += 1; + } + Ok(written) + } +} + +/// Write via temp-file + rename so a reader never observes a partial file. +/// +/// The temp name carries cosmic-config's `.atomicwrite` prefix +/// (`cosmic-config/src/lib.rs:408`) so its watcher ignores the intermediate +/// file and reacts only to the final rename. +fn atomic_write(path: &Path, contents: &str) -> io::Result<()> { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let tmp = dir.join(format!(".atomicwrite.{name}")); + + fs::write(&tmp, contents)?; + fs::rename(&tmp, path)?; + Ok(()) +} + +/// Render a scalar as the RON literal cosmic-config expects. +/// +/// Exact formatting is not load-bearing — cosmic-config reads with +/// `ron::from_str` (`lib.rs:468`) — but the *shape* is: `Option` has three +/// components, `Option` four. +fn render(v: &Value) -> String { + match v { + Value::Bool(b) => b.to_string(), + Value::U32(n) => n.to_string(), + Value::F32(n) => render_f32(*n), + Value::Str(s) => format!("{s:?}"), + Value::Rgb(r, g, b) => format!( + "Some((red: {}, green: {}, blue: {}))", + render_f32(byte_to_f32(*r)), + render_f32(byte_to_f32(*g)), + render_f32(byte_to_f32(*b)), + ), + Value::Rgba(r, g, b, a) => format!( + "Some((red: {}, green: {}, blue: {}, alpha: {}))", + render_f32(byte_to_f32(*r)), + render_f32(byte_to_f32(*g)), + render_f32(byte_to_f32(*b)), + render_f32(byte_to_f32(*a)), + ), + } +} + +fn byte_to_f32(b: u8) -> f32 { + b as f32 / 255.0 +} + +/// RON needs floats to look like floats: a bare `10` would deserialize as an +/// integer and fail a `f32` field. +fn render_f32(n: f32) -> String { + if n.fract() == 0.0 { + format!("{n:.1}") + } else { + format!("{n}") + } +} + +/// Rebuild a composite value from folded projections plus whatever is already +/// on disk. +/// +/// Only shapes that can be reconstructed correctly are supported. Anything else +/// is a hard error rather than a partial write, because silently writing an +/// incomplete composite would drop the user's other fields. +fn composite( + target: &TargetKey, + fields: &BTreeMap, Value>, + previous: Option<&str>, + path: &Path, +) -> Result { + match target.key.as_str() { + // ThemeBuilder.gaps: (u32, u32) ordered (outer, inner) — theme.rs:895, + // default (0, 8) — theme.rs:939. + "gaps" => { + let (mut outer, mut inner) = match previous { + Some(text) => ron::from_str::<(u32, u32)>(text).map_err(|e| EmitError::Unreadable { + path: path.to_path_buf(), + detail: e.to_string(), + })?, + None => (0, 8), + }; + + for (p, v) in fields { + let Value::U32(n) = v else { + return Err(EmitError::UnsupportedComposite { + key: target.key.clone(), + detail: format!("expected an integer for index {p:?}"), + }); + }; + match p.first().map(String::as_str) { + Some("0") => outer = *n, + Some("1") => inner = *n, + other => { + return Err(EmitError::UnsupportedComposite { + key: target.key.clone(), + detail: format!("unknown tuple index {other:?}"), + }) + } + } + } + Ok(format!("({outer}, {inner})")) + } + + // ThemeBuilder.corner_radii: six [f32; 4] fields — corner.rs:5. + "corner_radii" => { + let mut radii = match previous { + Some(text) => { + ron::from_str::(text).map_err(|e| EmitError::Unreadable { + path: path.to_path_buf(), + detail: e.to_string(), + })? + } + None => CornerRadii::default(), + }; + + for (p, v) in fields { + let Value::F32(n) = v else { + return Err(EmitError::UnsupportedComposite { + key: target.key.clone(), + detail: format!("expected a number for {p:?}"), + }); + }; + let Some(name) = p.first() else { + return Err(EmitError::UnsupportedComposite { + key: target.key.clone(), + detail: "missing radius name".into(), + }); + }; + let Some(slot) = radii.field_mut(name) else { + return Err(EmitError::UnsupportedComposite { + key: target.key.clone(), + detail: format!("unknown radius `{name}`"), + }); + }; + // A single `rounding` value applies to all four corners. + *slot = [*n; 4]; + } + + ron::ser::to_string_pretty(&radii, ron::ser::PrettyConfig::new()).map_err(|e| { + EmitError::UnsupportedComposite { + key: target.key.clone(), + detail: e.to_string(), + } + }) + } + + other => Err(EmitError::UnsupportedComposite { + key: other.to_string(), + detail: format!( + "composite shape not modelled yet; {} field(s) would be written blind", + fields.len() + ), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{parse, resolve}; + use tempfile::TempDir; + + fn plan_for(src: &str, root: &Path) -> Result, Vec> { + let ast = parse(src).expect("parse"); + let resolved = resolve(&ast).expect("resolve"); + Emitter::with_root(root).plan(&resolved) + } + + fn read(root: &Path, component: &str, key: &str) -> String { + fs::read_to_string(root.join(component).join("v1").join(key)) + .unwrap_or_else(|e| panic!("reading {component}/v1/{key}: {e}")) + } + + #[test] + fn writes_land_on_the_cosmic_config_path_layout() { + let tmp = TempDir::new().unwrap(); + let planned = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap(); + let e = Emitter::with_root(tmp.path()); + e.apply(&planned).unwrap(); + + assert_eq!(read(tmp.path(), "com.system76.CosmicComp", "autotile"), "true"); + } + + #[test] + fn scalars_render_as_ron_literals() { + let tmp = TempDir::new().unwrap(); + let src = "general {\n autotile = true\n edge_snap_threshold = 12\n}\n\ + theme {\n icon_theme = Tela-circle-dracula\n}\n"; + let planned = plan_for(src, tmp.path()).unwrap(); + Emitter::with_root(tmp.path()).apply(&planned).unwrap(); + + let root = tmp.path(); + assert_eq!(read(root, "com.system76.CosmicComp", "autotile"), "true"); + assert_eq!(read(root, "com.system76.CosmicComp", "edge_snap_threshold"), "12"); + assert_eq!( + read(root, "com.system76.CosmicTk", "icon_theme"), + "\"Tela-circle-dracula\"" + ); + } + + /// The end-to-end form of the folding property: both halves must reach disk + /// in one tuple. + #[test] + fn both_gaps_reach_disk_in_one_tuple() { + let tmp = TempDir::new().unwrap(); + let planned = plan_for("general {\n gaps_in = 3\n gaps_out = 8\n}\n", tmp.path()).unwrap(); + Emitter::with_root(tmp.path()).apply(&planned).unwrap(); + + // (outer, inner) — theme.rs:895 + for builder in [ + "com.system76.CosmicTheme.Dark.Builder", + "com.system76.CosmicTheme.Light.Builder", + ] { + assert_eq!(read(tmp.path(), builder, "gaps"), "(8, 3)"); + } + } + + /// cosmic-config is sparse: an unset key has no file, so a partial + /// projection must fall back to the verified default rather than zero. + #[test] + fn partial_projection_uses_the_verified_default() { + let tmp = TempDir::new().unwrap(); + let planned = plan_for("general {\n gaps_in = 5\n}\n", tmp.path()).unwrap(); + Emitter::with_root(tmp.path()).apply(&planned).unwrap(); + + // Default is (0, 8); only inner was set, so outer stays 0. + assert_eq!( + read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "gaps"), + "(0, 5)" + ); + } + + /// Read-modify-write must preserve the half the user did not mention. + #[test] + fn partial_projection_preserves_existing_sibling() { + let tmp = TempDir::new().unwrap(); + let dir = tmp + .path() + .join("com.system76.CosmicTheme.Dark.Builder") + .join("v1"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("gaps"), "(20, 4)").unwrap(); + + let planned = plan_for("general {\n gaps_in = 7\n}\n", tmp.path()).unwrap(); + Emitter::with_root(tmp.path()).apply(&planned).unwrap(); + + // Outer 20 survives; inner becomes 7. + assert_eq!( + read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "gaps"), + "(20, 7)" + ); + } + + #[test] + fn colors_render_with_the_right_component_count() { + let tmp = TempDir::new().unwrap(); + let src = "theme {\n accent = rgb(ff0000)\n bg_color = rgba(00ff0080)\n}\n"; + let planned = plan_for(src, tmp.path()).unwrap(); + Emitter::with_root(tmp.path()).apply(&planned).unwrap(); + + let b = "com.system76.CosmicTheme.Dark.Builder"; + // Option: three components, no alpha. + assert_eq!(read(tmp.path(), b, "accent"), "Some((red: 1.0, green: 0.0, blue: 0.0))"); + // Option: four. + let bg = read(tmp.path(), b, "bg_color"); + assert!(bg.starts_with("Some((red: 0.0, green: 1.0, blue: 0.0, alpha: "), "{bg}"); + } + + #[test] + fn rounding_sets_all_four_corners_of_radius_m() { + let tmp = TempDir::new().unwrap(); + let planned = plan_for("decoration {\n rounding = 10\n}\n", tmp.path()).unwrap(); + Emitter::with_root(tmp.path()).apply(&planned).unwrap(); + + let text = read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "corner_radii"); + let radii: CornerRadii = ron::from_str(&text).expect("round-trips as CornerRadii"); + assert_eq!(radii.radius_m, [10.0; 4]); + } + + #[test] + fn rounding_preserves_sibling_radii() { + // The other five radii must survive a read-modify-write untouched. + let tmp = TempDir::new().unwrap(); + let planned = plan_for("decoration {\n rounding = 10\n}\n", tmp.path()).unwrap(); + Emitter::with_root(tmp.path()).apply(&planned).unwrap(); + + let text = read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "corner_radii"); + let radii: CornerRadii = ron::from_str(&text).unwrap(); + let d = CornerRadii::default(); + assert_eq!(radii.radius_0, d.radius_0); + assert_eq!(radii.radius_xs, d.radius_xs); + assert_eq!(radii.radius_s, d.radius_s); + assert_eq!(radii.radius_l, d.radius_l); + assert_eq!(radii.radius_xl, d.radius_xl); + } + + #[test] + fn corner_radii_defaults_match_upstream() { + // Pinned against cosmic-theme/src/model/corner.rs:20-31. If upstream + // changes these, writing a sparse config would silently shift the theme. + let d = CornerRadii::default(); + assert_eq!(d.radius_0, [0.0; 4]); + assert_eq!(d.radius_xs, [4.0; 4]); + assert_eq!(d.radius_s, [8.0; 4]); + assert_eq!(d.radius_m, [16.0; 4]); + assert_eq!(d.radius_l, [32.0; 4]); + assert_eq!(d.radius_xl, [160.0; 4]); + } + + #[test] + fn genuinely_unmodelled_composite_is_still_refused() { + // A projected target with no shape handler must error rather than + // write a partial value. + let write = Write { + target: TargetKey { + component: "com.system76.Whatever".into(), + version: 1, + key: "palette".into(), + }, + kind: WriteKind::Projected(BTreeMap::from([( + vec!["bright_red".to_string()], + Value::U32(1), + )])), + }; + let tmp = TempDir::new().unwrap(); + let e = Emitter::with_root(tmp.path()); + let res = e.plan(&Resolved { writes: vec![write] }); + assert!( + matches!(res.unwrap_err()[0], EmitError::UnsupportedComposite { .. }), + ); + } + + #[test] + fn unparseable_existing_value_is_refused() { + let tmp = TempDir::new().unwrap(); + let dir = tmp + .path() + .join("com.system76.CosmicTheme.Dark.Builder") + .join("v1"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("gaps"), "not ron at all").unwrap(); + + let errs = plan_for("general {\n gaps_in = 3\n}\n", tmp.path()).unwrap_err(); + assert!(matches!(errs[0], EmitError::Unreadable { .. }), "{:?}", errs[0]); + } + + /// Planning must not touch disk — that is what makes emission transactional. + #[test] + fn plan_does_not_write() { + let tmp = TempDir::new().unwrap(); + let _ = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap(); + assert!( + fs::read_dir(tmp.path()).unwrap().next().is_none(), + "plan must leave the tree untouched" + ); + } + + #[test] + fn noop_writes_are_skipped() { + let tmp = TempDir::new().unwrap(); + let src = "general {\n autotile = true\n}\n"; + + let planned = plan_for(src, tmp.path()).unwrap(); + assert_eq!(Emitter::with_root(tmp.path()).apply(&planned).unwrap(), 1); + + // Second run sees identical contents and writes nothing. + let planned = plan_for(src, tmp.path()).unwrap(); + assert!(planned[0].is_noop()); + assert_eq!(Emitter::with_root(tmp.path()).apply(&planned).unwrap(), 0); + } + + #[test] + fn previous_contents_are_captured_for_diffing() { + let tmp = TempDir::new().unwrap(); + let first = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap(); + Emitter::with_root(tmp.path()).apply(&first).unwrap(); + + let second = plan_for("general {\n autotile = false\n}\n", tmp.path()).unwrap(); + assert_eq!(second[0].previous.as_deref(), Some("true")); + assert_eq!(second[0].contents, "false"); + } + + #[test] + fn atomic_write_leaves_no_temp_file() { + let tmp = TempDir::new().unwrap(); + let planned = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap(); + Emitter::with_root(tmp.path()).apply(&planned).unwrap(); + + let dir = tmp.path().join("com.system76.CosmicComp").join("v1"); + let leftovers: Vec<_> = fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n.starts_with(".atomicwrite")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + } + + #[test] + fn from_env_honours_xdg_config_home() { + // Uses the documented fallback chain rather than a hardcoded path. + let prev = std::env::var_os("XDG_CONFIG_HOME"); + std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-probe"); + let e = Emitter::from_env().unwrap(); + assert_eq!(e.root(), Path::new("/tmp/xdg-probe/cosmic")); + match prev { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } +} diff --git a/cosmic-conf/src/lib.rs b/cosmic-conf/src/lib.rs index a24de1d..b135603 100644 --- a/cosmic-conf/src/lib.rs +++ b/cosmic-conf/src/lib.rs @@ -8,10 +8,12 @@ //! COSMIC-specific, which keeps the hard logic testable without a compositor //! running. Only `emit` binds to cosmic-config, behind the `emit` feature. +pub mod emit; pub mod parser; pub mod resolve; pub mod schema; +pub use emit::{EmitError, Emitter, Planned}; pub use parser::{parse, Ast, ParseError, Span}; pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind}; diff --git a/cosmic-conf/src/main.rs b/cosmic-conf/src/main.rs new file mode 100644 index 0000000..d421834 --- /dev/null +++ b/cosmic-conf/src/main.rs @@ -0,0 +1,132 @@ +//! `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::PathBuf; +use std::process::ExitCode; + +use cosmic_conf::{emit::Emitter, parse, render_diagnostic, resolve}; + +const USAGE: &str = "\ +cosmic-conf — compile cosmic.conf into the cosmic-config tree + +USAGE: + cosmic-conf apply [--diff] [--config ] + +OPTIONS: + --diff Show what would change without writing anything + --config Config file (default: $XDG_CONFIG_HOME/hyprcosmic/cosmic.conf) + -h, --help Show this help +"; + +fn default_config_path() -> Option { + 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 = 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] != "apply" { + eprintln!("error: unknown command `{}`\n\n{USAGE}", args[0]); + 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); + } + }, + }; + + match run(&config_path, diff_only) { + Ok(msg) => { + println!("{msg}"); + ExitCode::SUCCESS + } + Err(msg) => { + eprint!("{msg}"); + ExitCode::from(1) + } + } +} + +fn run(config_path: &PathBuf, diff_only: bool) -> Result { + let source = std::fs::read_to_string(config_path) + .map_err(|e| format!("error: cannot read {}: {e}\n", config_path.display()))?; + + let ast = parse(&source).map_err(|e| { + render_diagnostic(&source, e.span, &e.message, None) + })?; + + let resolved = resolve(&ast).map_err(|diags| { + let mut out = String::new(); + for d in &diags { + out.push_str(&render_diagnostic(&source, d.span, &d.message, d.help.as_deref())); + out.push('\n'); + } + out.push_str(&format!( + "error: {} problem(s) found; nothing was written\n", + diags.len() + )); + out + })?; + + let emitter = Emitter::from_env().map_err(|e| format!("error: {e}\n"))?; + let planned = emitter.plan(&resolved).map_err(|errs| { + let mut out = String::new(); + for e in &errs { + out.push_str(&format!("error: {e}\n")); + } + out.push_str("error: nothing was written\n"); + out + })?; + + 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() + )) +} diff --git a/cosmic-conf/src/resolve.rs b/cosmic-conf/src/resolve.rs index 58a92f0..6436d7e 100644 --- a/cosmic-conf/src/resolve.rs +++ b/cosmic-conf/src/resolve.rs @@ -21,8 +21,10 @@ pub enum Value { U32(u32), F32(f32), Str(String), - /// Straight RGBA bytes; conversion to COSMIC's f32 colour struct happens in `emit`. - Color(u8, u8, u8, u8), + /// `Option` target — no alpha channel. + Rgb(u8, u8, u8), + /// `Option` target — carries alpha. + Rgba(u8, u8, u8, u8), } #[derive(Debug, Clone, PartialEq)] @@ -143,7 +145,7 @@ fn eval_arith(input: &str) -> String { /// Bare `#rrggbb` is deliberately **not** accepted: `#` begins a comment, so the /// value would be stripped before reaching here. Hyprland makes the same /// trade-off, and HyDE themes write colours as `rgba(...)`, so nothing is lost. -fn parse_color(raw: &str) -> Option { +fn parse_color(raw: &str) -> Option<(u8, u8, u8, u8)> { let s = raw.trim(); let hex = if let Some(inner) = s.strip_prefix("rgba(").and_then(|s| s.strip_suffix(')')) { inner.trim().to_string() @@ -154,19 +156,10 @@ fn parse_color(raw: &str) -> Option { }; let hex = hex.trim_start_matches('#'); + let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok(); match hex.len() { - 6 => Some(Value::Color( - u8::from_str_radix(&hex[0..2], 16).ok()?, - u8::from_str_radix(&hex[2..4], 16).ok()?, - u8::from_str_radix(&hex[4..6], 16).ok()?, - 255, - )), - 8 => Some(Value::Color( - u8::from_str_radix(&hex[0..2], 16).ok()?, - u8::from_str_radix(&hex[2..4], 16).ok()?, - u8::from_str_radix(&hex[4..6], 16).ok()?, - u8::from_str_radix(&hex[6..8], 16).ok()?, - )), + 6 => Some((byte(0)?, byte(2)?, byte(4)?, 255)), + 8 => Some((byte(0)?, byte(2)?, byte(4)?, byte(6)?)), _ => None, } } @@ -193,7 +186,17 @@ fn coerce(raw: &str, ty: Ty, span: Span) -> Result { .map(Value::F32) .map_err(|_| bad("a number")), Ty::Str => Ok(Value::Str(raw.to_string())), - Ty::Color => parse_color(raw).ok_or_else(|| bad("a colour like rgb(6b9fed) or rgba(6b9fed80)")), + Ty::Rgb => parse_color(raw) + .map(|(r, g, b, _)| Value::Rgb(r, g, b)) + .ok_or_else(|| bad("a colour like rgb(6b9fed)")), + Ty::Rgba => parse_color(raw) + .map(|(r, g, b, a)| Value::Rgba(r, g, b, a)) + .ok_or_else(|| bad("a colour like rgb(6b9fed) or rgba(6b9fed80)")), + Ty::Mode => match raw { + "dark" => Ok(Value::Bool(true)), + "light" => Ok(Value::Bool(false)), + _ => Err(bad("`dark` or `light`")), + }, } } @@ -425,14 +428,33 @@ mod tests { } #[test] - fn rgb_colors_parse() { + fn rgb_colors_parse_and_drop_alpha() { + // `accent` is Option — theme.rs:856 — so alpha must not appear. let r = resolved("theme {\n accent = rgb(6b9fed)\n}\n"); - match find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent") { - WriteKind::Projected(f) => { - assert_eq!(f[&Vec::::new()], Value::Color(0x6b, 0x9f, 0xed, 255)); - } - other => panic!("expected Projected, got {other:?}"), - } + assert_eq!( + find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent"), + &WriteKind::Whole(Value::Rgb(0x6b, 0x9f, 0xed)) + ); + } + + #[test] + fn theme_mode_maps_to_is_dark() { + let r = resolved("theme {\n mode = dark\n}\n"); + assert_eq!( + find(&r, "com.system76.CosmicTheme.Mode", "is_dark"), + &WriteKind::Whole(Value::Bool(true)) + ); + let r = resolved("theme {\n mode = light\n}\n"); + assert_eq!( + find(&r, "com.system76.CosmicTheme.Mode", "is_dark"), + &WriteKind::Whole(Value::Bool(false)) + ); + } + + #[test] + fn invalid_theme_mode_is_rejected() { + let d = errors("theme {\n mode = purple\n}\n"); + assert!(d[0].message.contains("`dark` or `light`"), "{}", d[0].message); } /// `#` always begins a comment, so a bare hex colour is stripped before it @@ -447,13 +469,12 @@ mod tests { #[test] fn rgba_keeps_alpha() { - let r = resolved("theme {\n accent = rgba(6b9fed80)\n}\n"); - match find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent") { - WriteKind::Projected(f) => { - assert_eq!(f[&Vec::::new()], Value::Color(0x6b, 0x9f, 0xed, 0x80)); - } - other => panic!("expected Projected, got {other:?}"), - } + // `bg_color` is Option — theme.rs:852 — so alpha survives. + let r = resolved("theme {\n bg_color = rgba(6b9fed80)\n}\n"); + assert_eq!( + find(&r, "com.system76.CosmicTheme.Dark.Builder", "bg_color"), + &WriteKind::Whole(Value::Rgba(0x6b, 0x9f, 0xed, 0x80)) + ); } #[test] diff --git a/cosmic-conf/src/schema.rs b/cosmic-conf/src/schema.rs index cf92faf..1cdcc50 100644 --- a/cosmic-conf/src/schema.rs +++ b/cosmic-conf/src/schema.rs @@ -11,9 +11,13 @@ pub enum Ty { U32, F32, Str, - /// `rgb(rrggbb)` or `rgba(rrggbbaa)`. Bare `#rrggbb` is not accepted — - /// `#` begins a comment. - Color, + /// `rgb(rrggbb)` -> `Option` (no alpha). Bare `#rrggbb` is not + /// accepted: `#` begins a comment. + Rgb, + /// `rgb(rrggbb)`/`rgba(rrggbbaa)` -> `Option` (with alpha). + Rgba, + /// `dark`/`light` -> the `is_dark` boolean. + Mode, } /// Where a conf key's value lands in the cosmic-config tree. @@ -105,6 +109,17 @@ macro_rules! both_themes { }; } +/// Whole-value fan-out across both theme builders. An empty projection path +/// would be a lie: these fields are `Option<..>` written in full. +macro_rules! both_themes_direct { + ($key:literal) => { + &[ + Target::Direct { component: DARK_BUILDER, version: 1, key: $key }, + Target::Direct { component: LIGHT_BUILDER, version: 1, key: $key }, + ] + }; +} + pub const REGISTRY: &[Entry] = &[ // ---- general --------------------------------------------------------- Entry { @@ -203,21 +218,21 @@ pub const REGISTRY: &[Entry] = &[ version: 1, key: "is_dark", }], - ty: Ty::Str, + ty: Ty::Mode, validate: None, doc: "`dark` or `light`", }, Entry { conf: "theme.accent", - targets: both_themes!("accent", &[]), - ty: Ty::Color, + targets: both_themes_direct!("accent"), + ty: Ty::Rgb, validate: None, doc: "Accent colour as rgb(rrggbb) or rgba(rrggbbaa)", }, Entry { conf: "theme.bg_color", - targets: both_themes!("bg_color", &[]), - ty: Ty::Color, + targets: both_themes_direct!("bg_color"), + ty: Ty::Rgba, validate: None, doc: "Background base colour", },