Skip to main content

max / alloy

13.0 KB · 347 lines History Blame Raw
1 //! Render Alloy's desktop skeleton from a makeover theme.
2 //!
3 //! Runs in the Containerfile's build stage, against the same theme files the
4 //! console loads, and writes a tree the runtime stage copies over `/`. Nothing
5 //! it emits is committed: the output is a pure function of the templates and
6 //! the theme, and CLAUDE.md says not to store what rebuilds from source.
7 //!
8 //! ```text
9 //! alloy-skelgen --templates templates/ --out /staged-skel \
10 //! --theme default=themes/akari-dawn.toml \
11 //! --theme night=themes/akari-night.toml
12 //! ```
13
14 use std::collections::BTreeMap;
15 use std::fs;
16 use std::path::{Path, PathBuf};
17
18 use anyhow::{Context, Result, anyhow, bail};
19 use clap::Parser;
20 use skelgen::{DEFAULT_THEME, Palette, render, theme_directive};
21
22 /// The suffix that marks a file as a template. Anything in the tree without it
23 /// is copied through, so a skeleton file with no colors in it does not have to
24 /// become a template to travel with its neighbours.
25 const TEMPLATE_SUFFIX: &str = ".in";
26
27 #[derive(Parser)]
28 #[command(
29 name = "alloy-skelgen",
30 about = "Render Alloy's desktop skeleton from a makeover theme"
31 )]
32 struct Args {
33 /// Template tree, mirroring the paths it renders to.
34 #[arg(long)]
35 templates: PathBuf,
36
37 /// Where to write the rendered tree.
38 #[arg(long)]
39 out: PathBuf,
40
41 /// A named theme, as `name=path/to/theme.toml`. Repeat for more than one.
42 /// The name `default` is what a template with no directive renders against.
43 #[arg(long = "theme", value_name = "NAME=PATH", required = true)]
44 themes: Vec<String>,
45 }
46
47 fn main() -> Result<()> {
48 let args = Args::parse();
49 let palettes = load_palettes(&args.themes)?;
50
51 if !palettes.contains_key(DEFAULT_THEME) {
52 bail!("no `{DEFAULT_THEME}=<path>` theme given; templates without a directive need one");
53 }
54
55 let counts = walk(&args.templates, &args.templates, &args.out, &palettes)?;
56
57 // Assert rather than trust, the way the Containerfile's theme glob and
58 // vtrgb steps do. Rendering nothing here is not a visible failure: the
59 // runtime stage copies an empty tree over `/`, every config falls back to
60 // its program's default, and the image boots looking like stock Fedora
61 // with no error anywhere in the build log.
62 if counts.templates == 0 {
63 bail!(
64 "no templates found under {}; expected files ending in `{TEMPLATE_SUFFIX}`",
65 args.templates.display()
66 );
67 }
68 // Both numbers, because a `variants` template writes more files than it is
69 // templates and the Containerfile's floor counts what landed on disk. One
70 // number would make the two guards look like they disagree.
71 eprintln!(
72 "skelgen: rendered {} templates to {} files in {}",
73 counts.templates,
74 counts.files,
75 args.out.display()
76 );
77 Ok(())
78 }
79
80 fn load_palettes(specs: &[String]) -> Result<BTreeMap<String, Palette>> {
81 let mut palettes = BTreeMap::new();
82 for spec in specs {
83 let (name, path) = spec
84 .split_once('=')
85 .ok_or_else(|| anyhow!("`--theme {spec}` is not `name=path`"))?;
86 let text = fs::read_to_string(path)
87 .with_context(|| format!("reading theme `{name}` from {path}"))?;
88 // Parsed from the path we were handed rather than looked up by id: the
89 // build stage stages exactly the two themes it means to ship, and a
90 // search path here would let a stray file on the box decide the image.
91 let theme = makeover::parse_theme_str(theme_id(path), &text, false)
92 .map_err(|e| anyhow!("{e}"))
93 .with_context(|| format!("parsing theme `{name}` from {path}"))?;
94 let palette = Palette::new(name, &theme)
95 .with_context(|| format!("resolving theme `{name}` from {path}"))?;
96 if palettes.insert(name.to_string(), palette).is_some() {
97 bail!("theme `{name}` given twice");
98 }
99 }
100 Ok(palettes)
101 }
102
103 /// A theme's id is its file stem, which is what `validate_theme_id` accepts and
104 /// what the `[meta]` block is filed under.
105 fn theme_id(path: &str) -> &str {
106 Path::new(path)
107 .file_stem()
108 .and_then(|s| s.to_str())
109 .unwrap_or("theme")
110 }
111
112 /// What a walk produced. Two numbers because they stopped being the same one:
113 /// a `variants` template is one template and several files.
114 #[derive(Debug, Default)]
115 struct Counts {
116 /// Templates read. The empty-tree guard is about this: zero here means the
117 /// tree was not found, however many files got copied through.
118 templates: usize,
119 /// Files written, renders and copies alike.
120 files: usize,
121 }
122
123 impl Counts {
124 fn add(&mut self, other: &Self) {
125 self.templates += other.templates;
126 self.files += other.files;
127 }
128 }
129
130 /// Walk the template tree, rendering `.in` files and copying the rest.
131 fn walk(
132 root: &Path,
133 dir: &Path,
134 out_root: &Path,
135 palettes: &BTreeMap<String, Palette>,
136 ) -> Result<Counts> {
137 let mut counts = Counts::default();
138 let entries =
139 fs::read_dir(dir).with_context(|| format!("reading directory {}", dir.display()))?;
140
141 for entry in entries {
142 let entry = entry?;
143 let path = entry.path();
144 if entry.file_type()?.is_dir() {
145 counts.add(&walk(root, &path, out_root, palettes)?);
146 continue;
147 }
148
149 let relative = path
150 .strip_prefix(root)
151 .expect("walk stays under the template root");
152 let name = relative
153 .to_str()
154 .ok_or_else(|| anyhow!("template path {} is not UTF-8", relative.display()))?;
155
156 let outputs = match name.strip_suffix(TEMPLATE_SUFFIX) {
157 Some(stripped) => {
158 let template = fs::read_to_string(&path)
159 .with_context(|| format!("reading template {}", path.display()))?;
160 let (directive, body) =
161 theme_directive(&template).with_context(|| format!("in {}", path.display()))?;
162 counts.templates += 1;
163 let mut outputs = Vec::new();
164 for (theme, target) in directive
165 .renders(stripped)
166 .with_context(|| format!("in {}", path.display()))?
167 {
168 let palette = palettes.get(&theme).ok_or_else(|| {
169 anyhow!(
170 "{} asks for theme `{theme}`, which was not passed with --theme",
171 path.display()
172 )
173 })?;
174 let out = render(&body, palette).with_context(|| {
175 format!("rendering {} against `{theme}`", path.display())
176 })?;
177 outputs.push((target, out));
178 }
179 outputs
180 }
181 None => vec![(
182 name.to_string(),
183 fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?,
184 )],
185 };
186
187 for (target, contents) in outputs {
188 let dest = out_root.join(&target);
189 if let Some(parent) = dest.parent() {
190 fs::create_dir_all(parent)
191 .with_context(|| format!("creating {}", parent.display()))?;
192 }
193 fs::write(&dest, contents).with_context(|| format!("writing {}", dest.display()))?;
194 copy_mode(&path, &dest)?;
195 counts.files += 1;
196 }
197 }
198 Ok(counts)
199 }
200
201 /// Carry the template's mode across, so an executable skeleton file stays one.
202 fn copy_mode(src: &Path, dest: &Path) -> Result<()> {
203 #[cfg(unix)]
204 {
205 use std::os::unix::fs::PermissionsExt;
206 let mode = fs::metadata(src)?.permissions().mode();
207 fs::set_permissions(dest, fs::Permissions::from_mode(mode))
208 .with_context(|| format!("setting mode on {}", dest.display()))?;
209 }
210 #[cfg(not(unix))]
211 {
212 let _ = (src, dest);
213 }
214 Ok(())
215 }
216
217 #[cfg(test)]
218 mod tests {
219 use super::*;
220
221 /// A scratch directory that cleans up after itself.
222 ///
223 /// Hand-rolled rather than a `tempfile` dependency: skelgen is a build-time
224 /// binary and this is the only place in the tree that wants one.
225 struct Scratch(PathBuf);
226
227 impl Scratch {
228 fn new(tag: &str) -> Self {
229 let unique = std::time::SystemTime::now()
230 .duration_since(std::time::UNIX_EPOCH)
231 .expect("the clock is past 1970")
232 .as_nanos();
233 let dir = std::env::temp_dir().join(format!("skelgen-{tag}-{unique}"));
234 fs::create_dir_all(&dir).expect("scratch directory is creatable");
235 Self(dir)
236 }
237
238 /// Write `body` to `name` under the scratch tree, making parents.
239 fn write(&self, name: &str, body: &str) -> PathBuf {
240 let path = self.0.join(name);
241 fs::create_dir_all(path.parent().expect("a named file has a parent"))
242 .expect("scratch parents are creatable");
243 fs::write(&path, body).expect("scratch file is writable");
244 path
245 }
246 }
247
248 impl Drop for Scratch {
249 fn drop(&mut self) {
250 let _ = fs::remove_dir_all(&self.0);
251 }
252 }
253
254 /// The two themes the image ships, under the names the Containerfile passes.
255 fn palettes() -> BTreeMap<String, Palette> {
256 let dir = makeover::bundled_themes_dir().expect("makeover bundles its themes");
257 let spec = |name: &str, id: &str| format!("{name}={}", dir.join(id).display());
258 load_palettes(&[
259 spec("default", "akari-dawn.toml"),
260 spec("night", "akari-night.toml"),
261 ])
262 .expect("the bundled themes resolve")
263 }
264
265 fn run(templates: &Path, out: &Path) -> Result<Counts> {
266 walk(templates, templates, out, &palettes())
267 }
268
269 // The case the whole extension exists for: one template, two files, and the
270 // light one at the path `/etc/skel` ships.
271 #[test]
272 fn a_variants_template_emits_the_plain_file_and_a_night_sibling() {
273 let tree = Scratch::new("variants");
274 let out = Scratch::new("variants-out");
275 tree.write(
276 ".config/mako/config.in",
277 "@{! variants = default, night }\nbackground-color=@{surface.raised}\n",
278 );
279
280 let counts = run(&tree.0, &out.0).expect("the tree renders");
281 assert_eq!(counts.templates, 1);
282 assert_eq!(counts.files, 2, "the reported count misses the sibling");
283
284 let day = fs::read_to_string(out.0.join(".config/mako/config")).expect("the plain file");
285 let night =
286 fs::read_to_string(out.0.join(".config/mako/config.night")).expect("the sibling");
287 assert!(!day.contains("@{"), "an expression survived: {day}");
288 assert_ne!(day, night, "both variants rendered against the same theme");
289 }
290
291 // The compatibility case. `theme = night` still means one file, at the plain
292 // path, with no sibling: Helix picks between its two themes by filename, so
293 // a `akari-night.toml.night` would be a second copy under a name Helix's
294 // `*.toml` scan never sees.
295 #[test]
296 fn a_single_theme_template_emits_one_file_and_no_sibling() {
297 let tree = Scratch::new("single");
298 let out = Scratch::new("single-out");
299 tree.write(
300 "themes/akari-night.toml.in",
301 "@{! theme = night }\n# @{meta.name}\nbackground = \"@{surface.page}\"\n",
302 );
303
304 let counts = run(&tree.0, &out.0).expect("the tree renders");
305 assert_eq!((counts.templates, counts.files), (1, 1));
306
307 let rendered =
308 fs::read_to_string(out.0.join("themes/akari-night.toml")).expect("the one output");
309 assert!(rendered.starts_with("# Akari Night"), "{rendered}");
310 assert!(!out.0.join("themes/akari-night.toml.night").exists());
311 }
312
313 // A name no `--theme` supplied has to stop the build. Left to run, the
314 // template would silently not be written at all.
315 #[test]
316 fn a_theme_the_build_did_not_pass_is_an_error() {
317 let tree = Scratch::new("unknown-theme");
318 let out = Scratch::new("unknown-theme-out");
319 tree.write(
320 "config.in",
321 "@{! variants = default, dusk }\n@{surface.page}\n",
322 );
323
324 let err = run(&tree.0, &out.0)
325 .expect_err("an unpassed theme stops the build")
326 .to_string();
327 assert!(err.contains("dusk"), "{err}");
328 }
329
330 // Files with no `.in` travel with their neighbours untouched, and count as
331 // files without counting as templates. The empty-tree guard reads the
332 // template number for exactly this reason.
333 #[test]
334 fn a_file_that_is_not_a_template_is_copied_and_is_not_a_template() {
335 let tree = Scratch::new("passthrough");
336 let out = Scratch::new("passthrough-out");
337 tree.write(".config/nushell/env.nu", "$env.EDITOR = \"hx\"\n");
338
339 let counts = run(&tree.0, &out.0).expect("the tree copies");
340 assert_eq!((counts.templates, counts.files), (0, 1));
341 assert_eq!(
342 fs::read_to_string(out.0.join(".config/nushell/env.nu")).unwrap(),
343 "$env.EDITOR = \"hx\"\n"
344 );
345 }
346 }
347