cosmic-conf: emit + CLI

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.
This commit is contained in:
2026-08-09 22:31:40 +07:00
parent 09f90f65de
commit dee24ac30a
8 changed files with 1041 additions and 38 deletions
+4
View File
@@ -0,0 +1,4 @@
# Single-core builds: this crate is developed on a machine where parallel
# rustc jobs are not wanted.
[build]
jobs = 1
+197
View File
@@ -2,6 +2,203 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "cosmic-conf" name = "cosmic-conf"
version = "0.1.0" 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",
]
+5
View File
@@ -6,6 +6,8 @@ license = "GPL-3.0-only"
description = "Compiles a single Hyprland-idiom config file into the cosmic-config tree" description = "Compiles a single Hyprland-idiom config file into the cosmic-config tree"
[dependencies] [dependencies]
ron = "0.12"
serde = { version = "1.0.229", features = ["derive"] }
[features] [features]
# `emit` links cosmic-config and the component crates. Off by default so the # `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. # dependency graph.
default = [] default = []
emit = [] emit = []
[dev-dependencies]
tempfile = "3.27.0"
+627
View File
@@ -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/<component>/v<n>/<key>`, 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<io::Error> 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<String>,
}
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<Self, EmitError> {
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<PathBuf>) -> 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<Planned>, Vec<EmitError>> {
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<Planned, EmitError> {
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<usize, EmitError> {
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<Srgb>` has three
/// components, `Option<Srgba>` 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<Vec<String>, Value>,
previous: Option<&str>,
path: &Path,
) -> Result<String, EmitError> {
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::<CornerRadii>(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<Planned>, Vec<EmitError>> {
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<Srgb>: three components, no alpha.
assert_eq!(read(tmp.path(), b, "accent"), "Some((red: 1.0, green: 0.0, blue: 0.0))");
// Option<Srgba>: 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"),
}
}
}
+2
View File
@@ -8,10 +8,12 @@
//! COSMIC-specific, which keeps the hard logic testable without a compositor //! COSMIC-specific, which keeps the hard logic testable without a compositor
//! running. Only `emit` binds to cosmic-config, behind the `emit` feature. //! running. Only `emit` binds to cosmic-config, behind the `emit` feature.
pub mod emit;
pub mod parser; pub mod parser;
pub mod resolve; pub mod resolve;
pub mod schema; pub mod schema;
pub use emit::{EmitError, Emitter, Planned};
pub use parser::{parse, Ast, ParseError, Span}; pub use parser::{parse, Ast, ParseError, Span};
pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind}; pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind};
+132
View File
@@ -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 <path>]
OPTIONS:
--diff Show what would change without writing anything
--config <path> Config file (default: $XDG_CONFIG_HOME/hyprcosmic/cosmic.conf)
-h, --help Show this help
";
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] != "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<String, String> {
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()
))
}
+51 -30
View File
@@ -21,8 +21,10 @@ pub enum Value {
U32(u32), U32(u32),
F32(f32), F32(f32),
Str(String), Str(String),
/// Straight RGBA bytes; conversion to COSMIC's f32 colour struct happens in `emit`. /// `Option<Srgb>` target — no alpha channel.
Color(u8, u8, u8, u8), Rgb(u8, u8, u8),
/// `Option<Srgba>` target — carries alpha.
Rgba(u8, u8, u8, u8),
} }
#[derive(Debug, Clone, PartialEq)] #[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 /// Bare `#rrggbb` is deliberately **not** accepted: `#` begins a comment, so the
/// value would be stripped before reaching here. Hyprland makes the same /// value would be stripped before reaching here. Hyprland makes the same
/// trade-off, and HyDE themes write colours as `rgba(...)`, so nothing is lost. /// trade-off, and HyDE themes write colours as `rgba(...)`, so nothing is lost.
fn parse_color(raw: &str) -> Option<Value> { fn parse_color(raw: &str) -> Option<(u8, u8, u8, u8)> {
let s = raw.trim(); let s = raw.trim();
let hex = if let Some(inner) = s.strip_prefix("rgba(").and_then(|s| s.strip_suffix(')')) { let hex = if let Some(inner) = s.strip_prefix("rgba(").and_then(|s| s.strip_suffix(')')) {
inner.trim().to_string() inner.trim().to_string()
@@ -154,19 +156,10 @@ fn parse_color(raw: &str) -> Option<Value> {
}; };
let hex = hex.trim_start_matches('#'); let hex = hex.trim_start_matches('#');
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
match hex.len() { match hex.len() {
6 => Some(Value::Color( 6 => Some((byte(0)?, byte(2)?, byte(4)?, 255)),
u8::from_str_radix(&hex[0..2], 16).ok()?, 8 => Some((byte(0)?, byte(2)?, byte(4)?, byte(6)?)),
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()?,
)),
_ => None, _ => None,
} }
} }
@@ -193,7 +186,17 @@ fn coerce(raw: &str, ty: Ty, span: Span) -> Result<Value, Diagnostic> {
.map(Value::F32) .map(Value::F32)
.map_err(|_| bad("a number")), .map_err(|_| bad("a number")),
Ty::Str => Ok(Value::Str(raw.to_string())), 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] #[test]
fn rgb_colors_parse() { fn rgb_colors_parse_and_drop_alpha() {
// `accent` is Option<Srgb> — theme.rs:856 — so alpha must not appear.
let r = resolved("theme {\n accent = rgb(6b9fed)\n}\n"); let r = resolved("theme {\n accent = rgb(6b9fed)\n}\n");
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent") { assert_eq!(
WriteKind::Projected(f) => { find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent"),
assert_eq!(f[&Vec::<String>::new()], Value::Color(0x6b, 0x9f, 0xed, 255)); &WriteKind::Whole(Value::Rgb(0x6b, 0x9f, 0xed))
} );
other => panic!("expected Projected, got {other:?}"), }
}
#[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 /// `#` always begins a comment, so a bare hex colour is stripped before it
@@ -447,13 +469,12 @@ mod tests {
#[test] #[test]
fn rgba_keeps_alpha() { fn rgba_keeps_alpha() {
let r = resolved("theme {\n accent = rgba(6b9fed80)\n}\n"); // `bg_color` is Option<Srgba> — theme.rs:852 — so alpha survives.
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent") { let r = resolved("theme {\n bg_color = rgba(6b9fed80)\n}\n");
WriteKind::Projected(f) => { assert_eq!(
assert_eq!(f[&Vec::<String>::new()], Value::Color(0x6b, 0x9f, 0xed, 0x80)); find(&r, "com.system76.CosmicTheme.Dark.Builder", "bg_color"),
} &WriteKind::Whole(Value::Rgba(0x6b, 0x9f, 0xed, 0x80))
other => panic!("expected Projected, got {other:?}"), );
}
} }
#[test] #[test]
+23 -8
View File
@@ -11,9 +11,13 @@ pub enum Ty {
U32, U32,
F32, F32,
Str, Str,
/// `rgb(rrggbb)` or `rgba(rrggbbaa)`. Bare `#rrggbb` is not accepted — /// `rgb(rrggbb)` -> `Option<Srgb>` (no alpha). Bare `#rrggbb` is not
/// `#` begins a comment. /// accepted: `#` begins a comment.
Color, Rgb,
/// `rgb(rrggbb)`/`rgba(rrggbbaa)` -> `Option<Srgba>` (with alpha).
Rgba,
/// `dark`/`light` -> the `is_dark` boolean.
Mode,
} }
/// Where a conf key's value lands in the cosmic-config tree. /// 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] = &[ pub const REGISTRY: &[Entry] = &[
// ---- general --------------------------------------------------------- // ---- general ---------------------------------------------------------
Entry { Entry {
@@ -203,21 +218,21 @@ pub const REGISTRY: &[Entry] = &[
version: 1, version: 1,
key: "is_dark", key: "is_dark",
}], }],
ty: Ty::Str, ty: Ty::Mode,
validate: None, validate: None,
doc: "`dark` or `light`", doc: "`dark` or `light`",
}, },
Entry { Entry {
conf: "theme.accent", conf: "theme.accent",
targets: both_themes!("accent", &[]), targets: both_themes_direct!("accent"),
ty: Ty::Color, ty: Ty::Rgb,
validate: None, validate: None,
doc: "Accent colour as rgb(rrggbb) or rgba(rrggbbaa)", doc: "Accent colour as rgb(rrggbb) or rgba(rrggbbaa)",
}, },
Entry { Entry {
conf: "theme.bg_color", conf: "theme.bg_color",
targets: both_themes!("bg_color", &[]), targets: both_themes_direct!("bg_color"),
ty: Ty::Color, ty: Ty::Rgba,
validate: None, validate: None,
doc: "Background base colour", doc: "Background base colour",
}, },