//! Render Alloy's desktop skeleton from a makeover theme. //! //! Runs in the Containerfile's build stage, against the same theme files the //! console loads, and writes a tree the runtime stage copies over `/`. Nothing //! it emits is committed: the output is a pure function of the templates and //! the theme, and CLAUDE.md says not to store what rebuilds from source. //! //! ```text //! alloy-skelgen --templates templates/ --out /staged-skel \ //! --theme default=themes/akari-dawn.toml \ //! --theme night=themes/akari-night.toml //! ``` use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow, bail}; use clap::Parser; use skelgen::{DEFAULT_THEME, Palette, render, theme_directive}; /// The suffix that marks a file as a template. Anything in the tree without it /// is copied through, so a skeleton file with no colors in it does not have to /// become a template to travel with its neighbours. const TEMPLATE_SUFFIX: &str = ".in"; #[derive(Parser)] #[command( name = "alloy-skelgen", about = "Render Alloy's desktop skeleton from a makeover theme" )] struct Args { /// Template tree, mirroring the paths it renders to. #[arg(long)] templates: PathBuf, /// Where to write the rendered tree. #[arg(long)] out: PathBuf, /// A named theme, as `name=path/to/theme.toml`. Repeat for more than one. /// The name `default` is what a template with no directive renders against. #[arg(long = "theme", value_name = "NAME=PATH", required = true)] themes: Vec, } fn main() -> Result<()> { let args = Args::parse(); let palettes = load_palettes(&args.themes)?; if !palettes.contains_key(DEFAULT_THEME) { bail!("no `{DEFAULT_THEME}=` theme given; templates without a directive need one"); } let counts = walk(&args.templates, &args.templates, &args.out, &palettes)?; // Assert rather than trust, the way the Containerfile's theme glob and // vtrgb steps do. Rendering nothing here is not a visible failure: the // runtime stage copies an empty tree over `/`, every config falls back to // its program's default, and the image boots looking like stock Fedora // with no error anywhere in the build log. if counts.templates == 0 { bail!( "no templates found under {}; expected files ending in `{TEMPLATE_SUFFIX}`", args.templates.display() ); } // Both numbers, because a `variants` template writes more files than it is // templates and the Containerfile's floor counts what landed on disk. One // number would make the two guards look like they disagree. eprintln!( "skelgen: rendered {} templates to {} files in {}", counts.templates, counts.files, args.out.display() ); Ok(()) } fn load_palettes(specs: &[String]) -> Result> { let mut palettes = BTreeMap::new(); for spec in specs { let (name, path) = spec .split_once('=') .ok_or_else(|| anyhow!("`--theme {spec}` is not `name=path`"))?; let text = fs::read_to_string(path) .with_context(|| format!("reading theme `{name}` from {path}"))?; // Parsed from the path we were handed rather than looked up by id: the // build stage stages exactly the two themes it means to ship, and a // search path here would let a stray file on the box decide the image. let theme = makeover::parse_theme_str(theme_id(path), &text, false) .map_err(|e| anyhow!("{e}")) .with_context(|| format!("parsing theme `{name}` from {path}"))?; let palette = Palette::new(name, &theme) .with_context(|| format!("resolving theme `{name}` from {path}"))?; if palettes.insert(name.to_string(), palette).is_some() { bail!("theme `{name}` given twice"); } } Ok(palettes) } /// A theme's id is its file stem, which is what `validate_theme_id` accepts and /// what the `[meta]` block is filed under. fn theme_id(path: &str) -> &str { Path::new(path) .file_stem() .and_then(|s| s.to_str()) .unwrap_or("theme") } /// What a walk produced. Two numbers because they stopped being the same one: /// a `variants` template is one template and several files. #[derive(Debug, Default)] struct Counts { /// Templates read. The empty-tree guard is about this: zero here means the /// tree was not found, however many files got copied through. templates: usize, /// Files written, renders and copies alike. files: usize, } impl Counts { fn add(&mut self, other: &Self) { self.templates += other.templates; self.files += other.files; } } /// Walk the template tree, rendering `.in` files and copying the rest. fn walk( root: &Path, dir: &Path, out_root: &Path, palettes: &BTreeMap, ) -> Result { let mut counts = Counts::default(); let entries = fs::read_dir(dir).with_context(|| format!("reading directory {}", dir.display()))?; for entry in entries { let entry = entry?; let path = entry.path(); if entry.file_type()?.is_dir() { counts.add(&walk(root, &path, out_root, palettes)?); continue; } let relative = path .strip_prefix(root) .expect("walk stays under the template root"); let name = relative .to_str() .ok_or_else(|| anyhow!("template path {} is not UTF-8", relative.display()))?; let outputs = match name.strip_suffix(TEMPLATE_SUFFIX) { Some(stripped) => { let template = fs::read_to_string(&path) .with_context(|| format!("reading template {}", path.display()))?; let (directive, body) = theme_directive(&template).with_context(|| format!("in {}", path.display()))?; counts.templates += 1; let mut outputs = Vec::new(); for (theme, target) in directive .renders(stripped) .with_context(|| format!("in {}", path.display()))? { let palette = palettes.get(&theme).ok_or_else(|| { anyhow!( "{} asks for theme `{theme}`, which was not passed with --theme", path.display() ) })?; let out = render(&body, palette).with_context(|| { format!("rendering {} against `{theme}`", path.display()) })?; outputs.push((target, out)); } outputs } None => vec![( name.to_string(), fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?, )], }; for (target, contents) in outputs { let dest = out_root.join(&target); if let Some(parent) = dest.parent() { fs::create_dir_all(parent) .with_context(|| format!("creating {}", parent.display()))?; } fs::write(&dest, contents).with_context(|| format!("writing {}", dest.display()))?; copy_mode(&path, &dest)?; counts.files += 1; } } Ok(counts) } /// Carry the template's mode across, so an executable skeleton file stays one. fn copy_mode(src: &Path, dest: &Path) -> Result<()> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = fs::metadata(src)?.permissions().mode(); fs::set_permissions(dest, fs::Permissions::from_mode(mode)) .with_context(|| format!("setting mode on {}", dest.display()))?; } #[cfg(not(unix))] { let _ = (src, dest); } Ok(()) } #[cfg(test)] mod tests { use super::*; /// A scratch directory that cleans up after itself. /// /// Hand-rolled rather than a `tempfile` dependency: skelgen is a build-time /// binary and this is the only place in the tree that wants one. struct Scratch(PathBuf); impl Scratch { fn new(tag: &str) -> Self { let unique = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("the clock is past 1970") .as_nanos(); let dir = std::env::temp_dir().join(format!("skelgen-{tag}-{unique}")); fs::create_dir_all(&dir).expect("scratch directory is creatable"); Self(dir) } /// Write `body` to `name` under the scratch tree, making parents. fn write(&self, name: &str, body: &str) -> PathBuf { let path = self.0.join(name); fs::create_dir_all(path.parent().expect("a named file has a parent")) .expect("scratch parents are creatable"); fs::write(&path, body).expect("scratch file is writable"); path } } impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } /// The two themes the image ships, under the names the Containerfile passes. fn palettes() -> BTreeMap { let dir = makeover::bundled_themes_dir().expect("makeover bundles its themes"); let spec = |name: &str, id: &str| format!("{name}={}", dir.join(id).display()); load_palettes(&[ spec("default", "akari-dawn.toml"), spec("night", "akari-night.toml"), ]) .expect("the bundled themes resolve") } fn run(templates: &Path, out: &Path) -> Result { walk(templates, templates, out, &palettes()) } // The case the whole extension exists for: one template, two files, and the // light one at the path `/etc/skel` ships. #[test] fn a_variants_template_emits_the_plain_file_and_a_night_sibling() { let tree = Scratch::new("variants"); let out = Scratch::new("variants-out"); tree.write( ".config/mako/config.in", "@{! variants = default, night }\nbackground-color=@{surface.raised}\n", ); let counts = run(&tree.0, &out.0).expect("the tree renders"); assert_eq!(counts.templates, 1); assert_eq!(counts.files, 2, "the reported count misses the sibling"); let day = fs::read_to_string(out.0.join(".config/mako/config")).expect("the plain file"); let night = fs::read_to_string(out.0.join(".config/mako/config.night")).expect("the sibling"); assert!(!day.contains("@{"), "an expression survived: {day}"); assert_ne!(day, night, "both variants rendered against the same theme"); } // The compatibility case. `theme = night` still means one file, at the plain // path, with no sibling: Helix picks between its two themes by filename, so // a `akari-night.toml.night` would be a second copy under a name Helix's // `*.toml` scan never sees. #[test] fn a_single_theme_template_emits_one_file_and_no_sibling() { let tree = Scratch::new("single"); let out = Scratch::new("single-out"); tree.write( "themes/akari-night.toml.in", "@{! theme = night }\n# @{meta.name}\nbackground = \"@{surface.page}\"\n", ); let counts = run(&tree.0, &out.0).expect("the tree renders"); assert_eq!((counts.templates, counts.files), (1, 1)); let rendered = fs::read_to_string(out.0.join("themes/akari-night.toml")).expect("the one output"); assert!(rendered.starts_with("# Akari Night"), "{rendered}"); assert!(!out.0.join("themes/akari-night.toml.night").exists()); } // A name no `--theme` supplied has to stop the build. Left to run, the // template would silently not be written at all. #[test] fn a_theme_the_build_did_not_pass_is_an_error() { let tree = Scratch::new("unknown-theme"); let out = Scratch::new("unknown-theme-out"); tree.write( "config.in", "@{! variants = default, dusk }\n@{surface.page}\n", ); let err = run(&tree.0, &out.0) .expect_err("an unpassed theme stops the build") .to_string(); assert!(err.contains("dusk"), "{err}"); } // Files with no `.in` travel with their neighbours untouched, and count as // files without counting as templates. The empty-tree guard reads the // template number for exactly this reason. #[test] fn a_file_that_is_not_a_template_is_copied_and_is_not_a_template() { let tree = Scratch::new("passthrough"); let out = Scratch::new("passthrough-out"); tree.write(".config/nushell/env.nu", "$env.EDITOR = \"hx\"\n"); let counts = run(&tree.0, &out.0).expect("the tree copies"); assert_eq!((counts.templates, counts.files), (0, 1)); assert_eq!( fs::read_to_string(out.0.join(".config/nushell/env.nu")).unwrap(), "$env.EDITOR = \"hx\"\n" ); } }