From ea5173dab8367c0644f597c4c51169c8113730f6 Mon Sep 17 00:00:00 2001 From: Jamy Golden Date: Wed, 23 Sep 2026 13:30:28 +0200 Subject: [PATCH] Add `--prune-stale` flag to prune unrecognised files in generated dir --- CHANGELOG.md | 11 + README.md | 3 +- tinted-builder-rust/Cargo.toml | 2 - tinted-builder-rust/README.md | 33 +- tinted-builder-rust/src/cli.rs | 6 + tinted-builder-rust/src/main.rs | 5 +- tinted-builder-rust/src/operations/build.rs | 339 ++++++++++++++++--- tinted-builder-rust/tests/build_flow.rs | 22 +- tinted-builder-rust/tests/operation_build.rs | 164 +++++++++ tinted-builder/Cargo.toml | 2 - 10 files changed, 522 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49cecc1..96a273e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ ### Added - Expose a `slug-underscored` variable, matching `scheme-slug-underscored` +- Add `build --prune` flag which deletes previously generated themes, or + unknown files in out directory, of files that aren't recognised. Renaming a + scheme previously left the old theme behind in every template repository + indefinitely. Pruning is limited to files matching a template config entry's + own `filename` pattern, skips scheme systems that generated nothing in the same + run, and only runs once every config entry has been generated successfully. + +### Changed + +- **Breaking (library):** `operation_build::build()` takes a new `prune` + argument before `is_quiet`. ## [0.21.0] - 2026-06-15 diff --git a/README.md b/README.md index 0b54a39..010497b 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ The following is a table of the available subcommands for the CLI tool (tinted-b | Subcommand | Description | Arguments | Example Usage | Flags | |------------|--------------------------------------|----------------------|--------------------------------------------|-------| | `sync` | Installs and or updates latest schemes. | - | `tinted-builder-rust sync` | `--quiet` (silence stderr and stdout) | -| `build` | Builds the themes of a template. | `template_path`: Path to template directory. | `tinted-builder-rust build ./path/to/base16-template` | `--quiet` (silence stderr and stdout), `--sync` (equivalent of running `tinted-builder-rust sync` before `tinted-builder-rust build`) | +| `build` | Builds the themes of a template. | `template_path`: Path to template directory. | `tinted-builder-rust build ./path/to/base16-template` | `--quiet` (silence stderr and stdout), `--sync` (equivalent of running `tinted-builder-rust sync` before `tinted-builder-rust build`), `--prune` (remove themes whose scheme no longer exists) | ## Flags @@ -76,6 +76,7 @@ The following is a table of the available subcommands for the CLI tool (tinted-b |-------------------|-----------------------------------------|------------------------|---------------|-------------------------------------------| | `--schemes-dir` `-s` | Path to local schemes directories. Used by `build` to find schemes and by `sync` to clone/pull into those paths. | `build`, `sync` | Defaults to `/schemes` | `tinted-builder-rust build . -s /path/one -s /path/two` | | `--ignore` `-i` | One or more glob patterns to skip when scanning schemes. Repeat this flag to add multiple ignores. | `build` | - | `tinted-builder-rust build . --ignore "**/LICENSE"` | +| `--prune` | Delete previously generated themes whose scheme no longer exists, such as the files left behind when a scheme is renamed. Only files matching a template config entry's own `filename` pattern are removed, and only for scheme systems that produced at least one theme in the same run. | `build` | Disabled | `tinted-builder-rust build . --prune` | | `--data-dir` `-d` | Specifies a custom path for the data directory. | All | Linux: `$XDG_DATA_HOME/tinted-theming/tinted-builder-rust` or `~/.local/share`. MacOS: `~/Library/Application\ Support/tinted-theming/tinted-builder-rust` | `tinted-builder-rust sync --data-dir /path/to/custom/data-dir` | | `--help` `-h` | Displays help information for the subcommand. | All | - | `tinted-builder-rust --help`, `tinted-builder-rust build --help`, etc | | `--version` `-V` | Shows the version of tinted-builder-rust. | All | - | `tinted-builder-rust --version` | diff --git a/tinted-builder-rust/Cargo.toml b/tinted-builder-rust/Cargo.toml index e68efdd..f6f987a 100644 --- a/tinted-builder-rust/Cargo.toml +++ b/tinted-builder-rust/Cargo.toml @@ -7,10 +7,8 @@ license = "GPL-3.0-only" description = "Simple Base16, Base24 and Tinted8 compliant rendering of mustache templates" keywords = ["base16", "tinted8", "mustache", "template"] categories = ["template-engine"] -homepage = "https://github.com/tinted-theming/tinted-builder-rust" repository = "https://github.com/tinted-theming/tinted-builder-rust" documentation = "https://github.com/tinted-theming/tinted-builder-rust" -readme = "README.md" rust-version.workspace = true [dependencies] diff --git a/tinted-builder-rust/README.md b/tinted-builder-rust/README.md index d7425a8..6d0de66 100644 --- a/tinted-builder-rust/README.md +++ b/tinted-builder-rust/README.md @@ -17,6 +17,7 @@ build templates within your own Rust application. - [Basic usage](#basic-usage) - [Commands](#commands) - [Flags](#flags) +- [Pruning themes](#pruning-themes) - [Builder specification](#builder-specification) - [Contributing](#contributing) - [License](#license) @@ -54,7 +55,7 @@ The following is a table of the available subcommands for the CLI tool (tinted-b | Subcommand | Description | Arguments | Example Usage | Flags | |------------|-------------|-----------|---------------|-------| | `sync` | Installs and or updates latest schemes. | - | `tinted-builder-rust sync` | `--quiet` (silence stderr and stdout) | -| `build` | Builds the themes of a template. | `template_path`: Path to template directory. | `tinted-builder-rust build ./path/to/base16-template` | `--quiet` (silence stderr and stdout), `--sync` (equivalent of running `tinted-builder-rust sync` before `tinted-builder-rust build`) | +| `build` | Builds the themes of a template. | `template_path`: Path to template directory. | `tinted-builder-rust build ./path/to/base16-template` | `--quiet` (silence stderr and stdout), `--sync` (equivalent of running `tinted-builder-rust sync` before `tinted-builder-rust build`), `--prune` (remove themes whose scheme no longer exists) | ## Flags @@ -62,10 +63,40 @@ The following is a table of the available subcommands for the CLI tool (tinted-b |-------------|-------------|------------------------|---------------|---------------| | `--schemes-dir` `-s` | Path to local schemes directories. Used by `build` to find schemes and by `sync` to clone/pull into those paths. | `build`, `sync` | Defaults to `/schemes` | `tinted-builder-rust build . -s /path/one -s /path/two` | | `--ignore` `-i` | One or more glob patterns to skip when scanning schemes. Repeat this flag to add multiple ignores. | `build` | - | `tinted-builder-rust build . --ignore "**/LICENSE"` | +| `--prune` | Delete previously generated themes whose scheme no longer exists, such as the files left behind when a scheme is renamed. See [Pruning themes](#pruning-themes). | `build` | Disabled | `tinted-builder-rust build . --prune` | | `--data-dir` `-d` | Specifies a custom path for the data directory. | All | Linux: `$XDG_DATA_HOME/tinted-theming/tinted-builder-rust`. macOS: `~/Library/Application\ Support/tinted-theming/tinted-builder-rust` | `tinted-builder-rust sync --data-dir /path/to/custom/data-dir` | | `--help` `-h` | Displays help information for the subcommand. | All | - | `tinted-builder-rust --help`, `tinted-builder-rust build --help`, etc | | `--version` `-V` | Shows the version of tinted-builder-rust. | All | - | `tinted-builder-rust --version` | +## Pruning theme files + +A build only ever writes files, so a theme generated for a scheme that has +since been renamed or deleted stays in the template repository forever. +Passing `--prune` removes those leftovers once the build has finished: + +```sh +tinted-builder-rust build . --prune +``` + +Pruning is deliberately conservative. A file is only removed when all of the +following hold: + +- It sits directly in the output directory of a `templates/config.yaml` entry, + and its name matches that entry's `filename` pattern with the scheme slug + substituted. For `colors/{{ scheme-system }}-{{ scheme-slug }}.conf`, that + means only `colors/base16-*.conf` is ever considered. +- It was not written by the current build. +- Its scheme system produced at least one theme in the current build, so a + partially synced or heavily ignored schemes directory cannot wipe out an + entire system's themes. +- It is not a dotfile and not a directory. + +Entries whose `filename` has no scheme slug in its last path component, such +as `list` templates, are skipped and reported as `W002`. + +Note that schemes excluded with `--ignore` count as non-existent, so their +previously generated themes are pruned too. + ## List usage `tinted-builder-rust` supports an `options.list` config property which enables diff --git a/tinted-builder-rust/src/cli.rs b/tinted-builder-rust/src/cli.rs index ed26c4b..4019490 100644 --- a/tinted-builder-rust/src/cli.rs +++ b/tinted-builder-rust/src/cli.rs @@ -52,6 +52,12 @@ fn build_cli() -> Command { .long("sync") .help("Sync with latest schemes before build") .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("prune") + .long("prune") + .help("Delete previously generated themes whose scheme no longer exists") + .action(ArgAction::SetTrue), ), ) .subcommand( diff --git a/tinted-builder-rust/src/main.rs b/tinted-builder-rust/src/main.rs index 6c6361d..a83917f 100644 --- a/tinted-builder-rust/src/main.rs +++ b/tinted-builder-rust/src/main.rs @@ -65,6 +65,9 @@ fn main() -> Result<()> { let is_quiet = sub_matches .get_one::("quiet") .is_some_and(ToOwned::to_owned); + let prune = sub_matches + .get_one::("prune") + .is_some_and(ToOwned::to_owned); let template_dir = sub_matches .get_one::("template-dir") .cloned() @@ -76,7 +79,7 @@ fn main() -> Result<()> { operations::sync::sync(&data_schemes_path, is_quiet)?; } - operations::build::build(&template_path, &schemes_path, &ignores, is_quiet)?; + operations::build::build(&template_path, &schemes_path, &ignores, prune, is_quiet)?; } Some(("sync", sub_matches)) => { let is_quiet: bool = sub_matches diff --git a/tinted-builder-rust/src/operations/build.rs b/tinted-builder-rust/src/operations/build.rs index 9e1cafb..651c206 100644 --- a/tinted-builder-rust/src/operations/build.rs +++ b/tinted-builder-rust/src/operations/build.rs @@ -3,7 +3,7 @@ pub mod utils; use crate::helpers::write_to_file; use anyhow::{anyhow, Result}; use semver::{Version, VersionReq}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs::{self, create_dir_all, read_to_string}; use std::path::{Path, PathBuf}; use tinted_builder::tinted8::{SUPPORTED_BUILDER_SPEC_VERSION, SUPPORTED_STYLING_SPEC_VERSION}; @@ -28,6 +28,9 @@ const REPO_NAME: &str = env!("CARGO_PKG_NAME"); /// directory or file. /// * `user_schemes_path` - A `impl AsRef` representing the directory where user schemes are /// stored. +/// * `prune` - A boolean flag that, when set to `true`, deletes previously generated themes +/// whose scheme no longer exists. Only files matching a template config entry's own `filename` +/// pattern are considered, and only once every entry has been generated successfully. /// * `is_quiet` - A boolean flag that, when set to `true`, suppresses most of the output, /// making the build process quieter. /// @@ -60,6 +63,7 @@ pub fn build( theme_template_path: impl AsRef, user_schemes_path: impl AsRef, ignores: &[String], + prune: bool, is_quiet: bool, ) -> Result<()> { if !user_schemes_path.as_ref().exists() { @@ -119,6 +123,12 @@ pub fn build( }) .collect::>>()?; + // Every path written by this build, and the set of directory/filename patterns those paths + // could have landed in. Pruning compares the two once every config entry has been generated, + // since sibling entries routinely share an output directory. + let mut generated_paths: HashSet = HashSet::new(); + let mut prune_scopes: HashSet = HashSet::new(); + // For each template definition in the templates/config.yaml file for (template_item_config_name, template_item_config_value) in &template_config { let supported_systems = template_item_config_value @@ -204,8 +214,154 @@ pub fn build( template_item_config_value, &theme_template_path, &template_item_scheme_files, + &mut generated_paths, is_quiet, )?; + + if prune { + let filename = get_filename(template_item_config_value, true)?; + + for system in &supported_systems { + // A system that produced nothing this run must not be pruned, otherwise a + // partially synced or heavily ignored schemes directory silently deletes every + // theme previously generated for it. + if !template_item_scheme_files + .iter() + .any(|(_, scheme)| scheme.get_scheme_system() == *system) + { + continue; + } + + match prune_scope(&theme_template_path, &filename, system) { + Some(scope) => { + prune_scopes.insert(scope); + } + None if !is_quiet => eprintln!( + "W002: Unable to prune themes for \"{template_item_config_name}\": \"{filename}\" has no scheme slug to match on" + ), + None => {} + } + } + } + } + + if prune { + prune_themes(&prune_scopes, &generated_paths, is_quiet)?; + } + + Ok(()) +} + +/// A bounded region of the output tree that pruning is allowed to delete from: files directly +/// inside `directory` whose name is `{prefix}{slug}{suffix}`. +/// +/// Anchoring on both sides is what keeps pruning safe. A pattern such as +/// `themes/ghostty/{{ scheme-system }}-{{ scheme-slug }}` produces no file extension at all, so +/// matching on the directory alone would sweep up a `README.md` sitting beside the themes. +#[derive(Debug, PartialEq, Eq, Hash)] +struct PruneScope { + directory: PathBuf, + prefix: String, + suffix: String, +} + +/// A byte that cannot appear in a path, used to locate the scheme slug within a filename pattern. +const SLUG_PLACEHOLDER: &str = "\u{0}"; + +/// Derives the prune scope for one template config entry and scheme system. +/// +/// Returns `None` when the pattern cannot be bounded safely: no slug in the final path component +/// (list templates), a slug in the directory portion, more than one slug, or a slug with nothing +/// around it to anchor against. +fn prune_scope( + theme_template_path: impl AsRef, + filename: &str, + system: &SchemeSystem, +) -> Option { + let system = system.to_string(); + let filepath = filename + .replace("{{ scheme-slug }}", SLUG_PLACEHOLDER) + .replace("{{scheme-slug}}", SLUG_PLACEHOLDER) + .replace("{{ scheme.slug }}", SLUG_PLACEHOLDER) + .replace("{{scheme.slug}}", SLUG_PLACEHOLDER) + .replace("{{ scheme-system }}", &system) + .replace("{{scheme-system}}", &system) + .replace("{{ scheme.system }}", &system) + .replace("{{scheme.system}}", &system); + + let path = Path::new(&filepath); + let (prefix, suffix) = path.file_name()?.to_str()?.split_once(SLUG_PLACEHOLDER)?; + + // A second slug would leave the middle unbounded, and a slug with no prefix or suffix would + // match every file in the directory. + if suffix.contains(SLUG_PLACEHOLDER) || (prefix.is_empty() && suffix.is_empty()) { + return None; + } + + let directory = path.parent().map_or_else( + || theme_template_path.as_ref().to_path_buf(), + |dir| theme_template_path.as_ref().join(dir), + ); + if directory.to_str()?.contains(SLUG_PLACEHOLDER) { + return None; + } + + Some(PruneScope { + directory, + prefix: prefix.to_string(), + suffix: suffix.to_string(), + }) +} + +/// Deletes files inside each scope that the current build did not generate. +fn prune_themes( + scopes: &HashSet, + generated_paths: &HashSet, + is_quiet: bool, +) -> Result<()> { + for scope in scopes { + if !scope.directory.is_dir() { + continue; + } + + let min_length = scope + .prefix + .len() + .checked_add(scope.suffix.len()) + .ok_or_else(|| { + anyhow!( + "E306: Filename pattern too long to prune: {}", + scope.directory.display() + ) + })?; + + for item in scope.directory.read_dir()? { + let file_path = item?.path(); + + if file_path.is_dir() || generated_paths.contains(&file_path) { + continue; + } + + let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + // `>` rather than `>=` so the slug itself is non-empty, and hidden files are never + // considered generated output. + if file_name.starts_with('.') + || file_name.len() <= min_length + || !file_name.starts_with(&scope.prefix) + || !file_name.ends_with(&scope.suffix) + { + continue; + } + + fs::remove_file(&file_path)?; + + if !is_quiet { + println!("✔ Removed theme \"{}\"", file_path.display()); + } + } } Ok(()) @@ -375,6 +531,7 @@ fn generate_themes_for_config( config_value: &TemplateConfig, theme_template_path: impl AsRef, scheme_files: &Vec<(PathBuf, Scheme)>, + generated_paths: &mut HashSet, is_quiet: bool, ) -> Result<()> { if scheme_files.is_empty() { @@ -458,12 +615,14 @@ fn generate_themes_for_config( create_dir_all(&parsed_filename.directory)?; } - generate_theme( + if let Some(output_path) = generate_theme( &template_content, parsed_filename, scheme_path, &scheme_system.clone(), - )?; + )? { + generated_paths.insert(output_path); + } } if !is_quiet { @@ -501,7 +660,8 @@ fn generate_themes_for_config( /// /// # Returns /// -/// Returns `Result<()>` indicating success (`Ok(())`) or an error (`Err`) if any of the following conditions are met: +/// Returns the path that was written, or `Ok(None)` for a skipped hidden file. Returns an error +/// (`Err`) if any of the following conditions are met: /// /// * The scheme file cannot be read or parsed. /// * The output directory cannot be created. @@ -525,7 +685,7 @@ fn generate_theme( parsed_filename: ParsedFilename, scheme_path: impl AsRef, system: &SchemeSystem, -) -> Result<()> { +) -> Result> { let scheme_file_type = SchemeFile::new(scheme_path)?; let scheme_path = scheme_file_type.get_path(); let scheme_file_stem = scheme_path @@ -536,61 +696,146 @@ fn generate_theme( // Ignore hidden files if scheme_file_stem.starts_with('.') { - return Ok(()); + return Ok(None); } let scheme = scheme_file_type.get_scheme()?; - match &scheme { - Scheme::Base16(scheme_inner) => { - if scheme_inner.system != *system { - return Err(anyhow!("E001: Invalid system")); - } + let scheme_system = match &scheme { + Scheme::Base16(scheme_inner) => &scheme_inner.system, + Scheme::Base24(scheme_inner) => &scheme_inner.system, + Scheme::Tinted8(scheme_inner) => &scheme_inner.scheme.system, + _ => return Err(anyhow!("Unknown Scheme enum variant")), + }; - let template = Template::new(template_content.to_string(), scheme.clone()); - let output = template.render()?; - let output_path = parsed_filename.get_path(); + if scheme_system != system { + return Err(anyhow!("E001: Invalid system")); + } - if !parsed_filename.directory.exists() { - fs::create_dir_all(parsed_filename.directory)?; - } + let template = Template::new(template_content.to_string(), scheme.clone()); + let output = template.render()?; + let output_path = parsed_filename.get_path(); - write_to_file(&output_path, &output)?; - } - Scheme::Base24(scheme_inner) => { - if scheme_inner.system != *system { - return Err(anyhow!("E001: Invalid system")); - } + if !parsed_filename.directory.exists() { + fs::create_dir_all(parsed_filename.directory)?; + } - let template = Template::new(template_content.to_string(), scheme.clone()); - let output = template.render()?; - let output_path = parsed_filename.get_path(); + write_to_file(&output_path, &output)?; - if !parsed_filename.directory.exists() { - fs::create_dir_all(parsed_filename.directory)?; - } + Ok(Some(output_path)) +} - write_to_file(&output_path, &output)?; - } - Scheme::Tinted8(scheme_inner) => { - if scheme_inner.scheme.system != *system { - return Err(anyhow!("E001: Invalid system")); - } +#[cfg(test)] +mod tests { + use super::*; - let template = Template::new(template_content.to_string(), scheme.clone()); - let output = template.render()?; - let output_path = parsed_filename.get_path(); + fn scope(filename: &str, system: &SchemeSystem) -> Option { + prune_scope(Path::new("/template"), filename, system) + } - if !parsed_filename.directory.exists() { - fs::create_dir_all(parsed_filename.directory)?; - } + #[test] + fn test_prune_scope_anchors_on_prefix_and_suffix() { + assert_eq!( + scope( + "colors/{{ scheme-system }}-{{ scheme-slug }}.conf", + &SchemeSystem::Base16 + ), + Some(PruneScope { + directory: PathBuf::from("/template/colors"), + prefix: "base16-".to_string(), + suffix: ".conf".to_string(), + }) + ); + } - write_to_file(&output_path, &output)?; - } - _ => { - return Err(anyhow!("Unknown Scheme enum variant")); - } + #[test] + fn test_prune_scope_with_dot_notation_variables() { + assert_eq!( + scope( + "scripts/{{scheme.system}}-{{scheme.slug}}.sh", + &SchemeSystem::Tinted8 + ), + Some(PruneScope { + directory: PathBuf::from("/template/scripts"), + prefix: "tinted8-".to_string(), + suffix: ".sh".to_string(), + }) + ); } - Ok(()) + /// tinted-terminal's ghostty themes have no file extension, so the prefix is the only anchor. + #[test] + fn test_prune_scope_without_file_extension() { + assert_eq!( + scope( + "themes/ghostty/{{ scheme-system }}-{{ scheme-slug }}", + &SchemeSystem::Base24 + ), + Some(PruneScope { + directory: PathBuf::from("/template/themes/ghostty"), + prefix: "base24-".to_string(), + suffix: String::new(), + }) + ); + } + + /// tinted-terminal's st themes wrap the slug in both a prefix and a version suffix. + #[test] + fn test_prune_scope_with_text_on_both_sides_of_the_slug() { + assert_eq!( + scope( + "themes/st/st-{{ scheme-system }}-{{ scheme-slug }}-0.9.3.diff", + &SchemeSystem::Base16 + ), + Some(PruneScope { + directory: PathBuf::from("/template/themes/st"), + prefix: "st-base16-".to_string(), + suffix: "-0.9.3.diff".to_string(), + }) + ); + } + + #[test] + fn test_prune_scope_at_template_root() { + assert_eq!( + scope( + "{{ scheme-system }}-{{ scheme-slug }}.md", + &SchemeSystem::Base16 + ), + Some(PruneScope { + directory: PathBuf::from("/template"), + prefix: "base16-".to_string(), + suffix: ".md".to_string(), + }) + ); + } + + /// A list template renders a single file, so there is no slug to match siblings against. + #[test] + fn test_prune_scope_none_without_a_slug() { + assert_eq!( + scope("{{ scheme-system }}-list.md", &SchemeSystem::Base16), + None + ); + } + + /// Nothing anchors the match, so every file in the directory would qualify. + #[test] + fn test_prune_scope_none_when_slug_is_the_whole_filename() { + assert_eq!( + scope("themes/{{ scheme-slug }}", &SchemeSystem::Base16), + None + ); + } + + #[test] + fn test_prune_scope_none_when_slug_is_in_the_directory() { + assert_eq!( + scope( + "themes/{{ scheme-slug }}/{{ scheme-system }}-{{ scheme-slug }}.conf", + &SchemeSystem::Base16 + ), + None + ); + } } diff --git a/tinted-builder-rust/tests/build_flow.rs b/tinted-builder-rust/tests/build_flow.rs index fc2c712..2968827 100644 --- a/tinted-builder-rust/tests/build_flow.rs +++ b/tinted-builder-rust/tests/build_flow.rs @@ -15,7 +15,7 @@ fn e305_missing_template_config() -> Result<()> { create_dir_all(&template)?; #[allow(clippy::unwrap_used)] - let err = tinted_builder_rust::build(&template, &schemes, &[], true).unwrap_err(); + let err = tinted_builder_rust::build(&template, &schemes, &[], false, true).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("E305"), "expected E305, got: {msg}"); @@ -59,7 +59,7 @@ default: write_to_file(templates_dir.join("default.mustache"), "{{scheme.name}}\n")?; #[allow(clippy::unwrap_used)] - let err = tinted_builder_rust::build(&template, &schemes, &[], true).unwrap_err(); + let err = tinted_builder_rust::build(&template, &schemes, &[], false, true).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("E300"), "expected E300, got: {msg}"); @@ -105,7 +105,7 @@ default: write_to_file(templates_dir.join("config.yaml"), config)?; #[allow(clippy::unwrap_used)] - let err = tinted_builder_rust::build(&template, &schemes, &[], true).unwrap_err(); + let err = tinted_builder_rust::build(&template, &schemes, &[], false, true).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("E303"), "expected E303, got: {msg}"); @@ -151,7 +151,7 @@ default: write_to_file(templates_dir.join("default.mustache"), "Hello\n")?; #[allow(clippy::unwrap_used)] - let err = tinted_builder_rust::build(&template, &schemes, &[], true).unwrap_err(); + let err = tinted_builder_rust::build(&template, &schemes, &[], false, true).unwrap_err(); assert!(err.to_string().contains("E002")); Ok(()) @@ -196,7 +196,7 @@ default: write_to_file(templates_dir.join("default.mustache"), "Hello\n")?; #[allow(clippy::unwrap_used)] - let err = tinted_builder_rust::build(&template, &schemes, &[], true).unwrap_err(); + let err = tinted_builder_rust::build(&template, &schemes, &[], false, true).unwrap_err(); assert!(err.to_string().contains("E003")); Ok(()) @@ -240,7 +240,7 @@ default: write_to_file(templates_dir.join("default.mustache"), "Hello\n")?; #[allow(clippy::unwrap_used)] - let err = tinted_builder_rust::build(&template, &schemes, &[], true).unwrap_err(); + let err = tinted_builder_rust::build(&template, &schemes, &[], false, true).unwrap_err(); assert!(err.to_string().contains("E301")); Ok(()) @@ -285,7 +285,7 @@ default: write_to_file(templates_dir.join("default.mustache"), "Hello\n")?; #[allow(clippy::unwrap_used)] - let err = tinted_builder_rust::build(&template, &schemes, &[], true).unwrap_err(); + let err = tinted_builder_rust::build(&template, &schemes, &[], false, true).unwrap_err(); assert!(err.to_string().contains("E302")); Ok(()) @@ -317,7 +317,7 @@ default: write_to_file(templates_dir.join("default.mustache"), "Hello\n")?; // No schemes are present, but discovery must not fail on the non-scheme files. - tinted_builder_rust::build(&template, &schemes, &[], true)?; + tinted_builder_rust::build(&template, &schemes, &[], false, true)?; Ok(()) } @@ -362,7 +362,7 @@ default: write_to_file(templates_dir.join("default.mustache"), "Hello\n")?; #[allow(clippy::unwrap_used)] - let err = tinted_builder_rust::build(&template, &schemes, &[], true).unwrap_err(); + let err = tinted_builder_rust::build(&template, &schemes, &[], false, true).unwrap_err(); assert!(err.to_string().contains("E110")); Ok(()) @@ -469,7 +469,7 @@ default: "Hello {{scheme.name}}\nBlue is #{{palette.blue.normal.hex}}", )?; - tinted_builder_rust::build(&template, &schemes, &[], true)?; + tinted_builder_rust::build(&template, &schemes, &[], false, true)?; let out_path = template.join("out/tinted8-test.txt"); let out = fs::read_to_string(&out_path)?; @@ -587,7 +587,7 @@ fn build_renders_system_dir_scheme_despite_root_junk() -> Result<()> { write_to_file(templates_dir.join("config.yaml"), &config)?; write_to_file(templates_dir.join("base16-template.mustache"), &mustache)?; - tinted_builder_rust::build(&template, &schemes, &[], true)?; + tinted_builder_rust::build(&template, &schemes, &[], false, true)?; let output_dir = template.join("output-themes"); let rendered_count = fs::read_dir(&output_dir)?.count(); diff --git a/tinted-builder-rust/tests/operation_build.rs b/tinted-builder-rust/tests/operation_build.rs index a9ce1b8..e29da7b 100644 --- a/tinted-builder-rust/tests/operation_build.rs +++ b/tinted-builder-rust/tests/operation_build.rs @@ -442,6 +442,170 @@ fn test_operation_build_multi_ignore_input() -> Result<()> { Ok(()) } +/// Arranges an output directory that already holds a theme for a since-renamed scheme, alongside +/// files the builder never generated. Returns the paths the assertions care about. +fn setup_output( + test_name: &str, +) -> Result<(PathBuf, PathBuf, PathBuf, Vec, String, String)> { + let scheme_name = "silk-light"; + let tmp_dir = unique_tmp_dir(test_name)?; + let template_theme_path = tmp_dir.join("template"); + let template_templates_path = template_theme_path.join("templates"); + let schemes_path = tmp_dir.join("schemes"); + let themes_path = template_theme_path.join("output-themes"); + let nested_path = themes_path.join("nested"); + + let (config_file_content, scheme_file_content, template_file_content, rendered_fixture) = + setup("base16", scheme_name)?; + + fs::create_dir_all(&template_templates_path)?; + fs::create_dir_all(&schemes_path)?; + fs::create_dir_all(&nested_path)?; + write_to_file( + schemes_path.join(format!("{scheme_name}.yaml")), + &scheme_file_content, + )?; + write_to_file( + template_templates_path.join("config.yaml"), + &config_file_content, + )?; + write_to_file( + template_templates_path.join("base16-template.mustache"), + &template_file_content, + )?; + + // The scheme this was generated for has since been renamed, so nothing regenerates it. + let theme_path = themes_path.join("base16-booshnickle-dark.md"); + write_to_file(&theme_path, "Theme content")?; + + // None of these were generated by the builder and none may be pruned: the README and the + // nested theme fail the `base16-` prefix or the `.md` suffix, and dotfiles are never touched. + let bystander_paths = vec![ + themes_path.join("README.md"), + themes_path.join("base16-notes.txt"), + themes_path.join(".base16-hidden.md"), + nested_path.join("base16-nested.md"), + ]; + for path in &bystander_paths { + write_to_file(path, "Not generated by the builder")?; + } + + Ok(( + template_theme_path, + schemes_path, + theme_path, + bystander_paths, + themes_path + .join(format!("base16-{scheme_name}.md")) + .display() + .to_string(), + rendered_fixture, + )) +} + +/// Tests that `--prune` deletes themes whose scheme no longer exists +#[test] +fn test_operation_build_prune() -> Result<()> { + // ------- + // Arrange + // ------- + let ( + template_theme_path, + schemes_path, + theme_path, + bystander_paths, + rendered_theme_path, + rendered_fixture, + ) = setup_output("operation_build_prune")?; + + // --- + // Act + // --- + let (stdout, stderr) = run_command(&[ + "build".to_string(), + template_theme_path.display().to_string(), + format!("--schemes-dir={}", schemes_path.display()), + "--prune".to_string(), + ]) + .expect("Unable to run command"); + + // ------ + // Assert + // ------ + assert_eq!(fs::read_to_string(&rendered_theme_path)?, rendered_fixture); + assert!( + stderr.is_empty(), + "stderr does not contain the expected output" + ); + assert!( + !theme_path.exists(), + "theme should have been pruned: {}", + theme_path.display() + ); + for path in &bystander_paths { + assert!( + path.is_file(), + "file should not have been pruned: {}", + path.display() + ); + } + assert!( + stdout.contains(&format!("✔ Removed theme \"{}\"", theme_path.display())), + "stdout does not contain the exptected output" + ); + + Ok(()) +} + +/// Tests that themes are left alone unless `--prune` is passed +#[test] +fn test_operation_build_without_prune() -> Result<()> { + // ------- + // Arrange + // ------- + let ( + template_theme_path, + schemes_path, + theme_path, + bystander_paths, + rendered_theme_path, + rendered_fixture, + ) = setup_output("operation_build_without_prune")?; + + // --- + // Act + // --- + let (stdout, stderr) = run_command(&[ + "build".to_string(), + template_theme_path.display().to_string(), + format!("--schemes-dir={}", schemes_path.display()), + ]) + .expect("Unable to run command"); + + // ------ + // Assert + // ------ + assert_eq!(fs::read_to_string(&rendered_theme_path)?, rendered_fixture); + assert!( + stderr.is_empty(), + "stderr does not contain the expected output" + ); + assert!( + theme_path.is_file(), + "theme should have been left alone: {}", + theme_path.display() + ); + for path in &bystander_paths { + assert!(path.is_file(), "file does not exist: {}", path.display()); + } + assert!( + !stdout.contains("Removed theme"), + "stdout does not contain the exptected output" + ); + + Ok(()) +} + #[test] fn test_operation_build_listbase16() -> Result<()> { // ------- diff --git a/tinted-builder/Cargo.toml b/tinted-builder/Cargo.toml index 95a451f..6042266 100644 --- a/tinted-builder/Cargo.toml +++ b/tinted-builder/Cargo.toml @@ -4,9 +4,7 @@ description = "A Tinted Theming template builder which uses yaml color schemes t version = "0.16.0" edition = "2021" license = "GPL-3.0-only" -readme = "README.md" authors = ["Jamy Golden ", "Tinted Theming "] -homepage = "https://github.com/tinted-theming/tinted-builder-rust" repository = "https://github.com/tinted-theming/tinted-builder-rust" keywords = ["cli", "base16", "base24", "tinted-theming"] categories = ["command-line-utilities", "template-engine"]