aboutsummaryrefslogtreecommitdiff
path: root/mingling_cli/src/proj_mgr
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_cli/src/proj_mgr')
-rw-r--r--mingling_cli/src/proj_mgr/cmd_class_add.rs344
-rw-r--r--mingling_cli/src/proj_mgr/cmd_proj_init.rs365
-rw-r--r--mingling_cli/src/proj_mgr/rule_solver.rs675
-rw-r--r--mingling_cli/src/proj_mgr/template_source.rs365
4 files changed, 1749 insertions, 0 deletions
diff --git a/mingling_cli/src/proj_mgr/cmd_class_add.rs b/mingling_cli/src/proj_mgr/cmd_class_add.rs
new file mode 100644
index 0000000..9c729a9
--- /dev/null
+++ b/mingling_cli/src/proj_mgr/cmd_class_add.rs
@@ -0,0 +1,344 @@
+use std::{
+ fs,
+ path::{Path, PathBuf},
+};
+
+use just_fmt::{camel_case, kebab_case, pascal_case, snake_case};
+use just_template::Template;
+use mingling::{
+ Grouped, RenderResult, Routable, ShellContext, Suggest, SuggestItem,
+ macros::{arg, chain, command, completion, metadata, pack, pack_err, renderer, routeify},
+ metadata::Description,
+ picker::EntryPicker,
+ res::ResCurrentDir,
+};
+use toml_edit::DocumentMut;
+
+use crate::{Next, eprintln_cargo, println_cargo};
+
+/// A `[[classes]]` entry in `.mling/classes.toml`.
+#[derive(Debug, Clone)]
+pub struct ClassEntry {
+ /// Class type name, e.g. `subcommand`.
+ pub name: String,
+ /// Template path relative to `.mling/`, e.g. `classes/subcommand.rs`.
+ pub template: String,
+ /// Output directory relative to the project root, e.g. `src/command/`.
+ pub output_dir: String,
+ /// Short description shown in completions, may be empty.
+ pub description: String,
+}
+
+pack!(StateClassAdd = (String, String));
+
+/// Result of adding a class: the generated file path.
+#[derive(Debug, Default, Grouped)]
+pub struct ResultClassAdd {
+ pub output: PathBuf,
+}
+
+pack_err!(ErrorClassNameRequired = ());
+pack_err!(ErrorClassConfigMissing = String);
+pack_err!(ErrorClassNotFound = String);
+pack_err!(ErrorClassTemplateMissing = String);
+pack_err!(ErrorClassWriteFailed = String);
+
+#[command(node = "class-add", routeify)]
+pub fn class_add(args: EntryClassAdd) -> Next {
+ let (class_name, name) = args
+ .pick_or_route(&arg![String], || ErrorClassNameRequired::new(()).to_chain())
+ .pick_or_route(&arg![String], || ErrorClassNameRequired::new(()).to_chain())
+ .to_result()?;
+ StateClassAdd::new((class_name, name)).to_chain()
+}
+
+/// Walk upward from `start` to find the first directory containing `.mling`.
+fn find_project_root(start: &Path) -> Option<PathBuf> {
+ let mut dir = deverbatim(&fs::canonicalize(start).ok()?);
+ loop {
+ if dir.join(".mling").is_dir() {
+ return Some(dir);
+ }
+ if !dir.pop() {
+ return None;
+ }
+ }
+}
+
+/// Deverbatim a Windows path: strip the `\\?\` prefix that `fs::canonicalize`
+/// may add. On non-Windows platforms this is a no-op.
+///
+/// On Windows, canonical paths sometimes carry a verbatim prefix like
+/// `\\?\C:\...`. This function removes that prefix. For UNC paths such as
+/// `\\?\UNC\server\share`, the prefix is converted back to the conventional
+/// `\\server\share` form.
+fn deverbatim(path: &Path) -> PathBuf {
+ if !cfg!(windows) {
+ return path.to_path_buf();
+ }
+ let as_string = path.to_string_lossy();
+ if let Some(rest) = as_string.strip_prefix(r"\\?\") {
+ // Turns `\\?\UNC\server\share` back into `\\server\share`.
+ if let Some(share) = rest.strip_prefix("UNC\\") {
+ return PathBuf::from(format!(r"\\{share}"));
+ }
+ return PathBuf::from(rest.to_owned());
+ }
+ path.to_path_buf()
+}
+
+/// Read `.mling/classes.toml`, find the class template and render it with the
+/// name-derived parameters into `<output-dir>/<snake_case>.rs`.
+#[chain(routeify)]
+pub fn handle_state_class_add(state: StateClassAdd, cwd: &ResCurrentDir) -> Next {
+ let (class_name, name) = state.inner;
+
+ // Resolve the project root: the nearest ancestor directory with `.mling`.
+ let Some(project_root) = find_project_root(cwd) else {
+ return ErrorClassConfigMissing::new(format!(
+ "no `.mling` directory found from {} upward; run this inside a mingling project",
+ cwd.display()
+ ))
+ .to_chain();
+ };
+
+ // Read `.mling/classes.toml` (the class registry).
+ let classes_path = project_root.join(".mling").join("classes.toml");
+ let content = fs::read_to_string(&classes_path).map_err(|e| {
+ ErrorClassConfigMissing::new(format!("failed to read {}: {e}", classes_path.display()))
+ })?;
+ let classes = parse_classes(&content)
+ .map_err(|e| ErrorClassConfigMissing::new(format!("invalid classes.toml: {e}")))?;
+
+ // Find the requested class type.
+ let Some(entry) = classes.iter().find(|c| c.name == class_name) else {
+ return ErrorClassNotFound::new(format!(
+ "class `{class_name}` not found in {}",
+ classes_path.display()
+ ))
+ .to_chain();
+ };
+
+ // Read the class template (relative to `.mling/`).
+ let template_path = project_root.join(".mling").join(&entry.template);
+ let template_content = fs::read_to_string(&template_path).map_err(|e| {
+ ErrorClassTemplateMissing::new(format!("failed to read {}: {e}", template_path.display()))
+ })?;
+
+ // Derive the name variants used by the template placeholders.
+ let snake = snake_case!(name.as_str());
+ let pascal = pascal_case!(name.as_str());
+ let kebab = kebab_case!(name.as_str());
+ let upper_snake = snake.to_uppercase();
+ let camel = camel_case!(name.as_str());
+
+ let mut tmpl = Template::from(template_content);
+ tmpl.insert_param("snake_case".to_string(), snake.clone());
+ tmpl.insert_param("pascal_case".to_string(), pascal);
+ tmpl.insert_param("kebab_case".to_string(), kebab);
+ tmpl.insert_param("upper_snake_case".to_string(), upper_snake);
+ tmpl.insert_param("camel_case".to_string(), camel);
+ let expanded = tmpl.expand().ok_or_else(|| {
+ ErrorClassWriteFailed::new(format!(
+ "failed to expand class template: {}",
+ template_path.display()
+ ))
+ })?;
+
+ // Write to `<output-dir>/<snake_case>.rs`.
+ let output_dir = project_root.join(&entry.output_dir);
+ let output = output_dir.join(format!("{snake}.rs"));
+ fs::create_dir_all(&output_dir).map_err(|e| {
+ ErrorClassWriteFailed::new(format!("failed to create {}: {e}", output_dir.display()))
+ })?;
+ fs::write(&output, expanded).map_err(|e| {
+ ErrorClassWriteFailed::new(format!("failed to write {}: {e}", output.display()))
+ })?;
+
+ ResultClassAdd { output }.to_chain()
+}
+
+/// Parse `.mling/classes.toml` into class entries.
+fn parse_classes(content: &str) -> Result<Vec<ClassEntry>, String> {
+ let doc = content.parse::<DocumentMut>().map_err(|e| e.to_string())?;
+ let mut entries = Vec::new();
+ if let Some(tables) = doc
+ .get("classes")
+ .and_then(|item| item.as_array_of_tables())
+ {
+ for table in tables {
+ let name = table
+ .get("name")
+ .and_then(|v| v.as_str())
+ .ok_or("[[classes]] entry is missing `name`")?
+ .to_string();
+ let template = table
+ .get("template")
+ .and_then(|v| v.as_str())
+ .ok_or("[[classes]] entry is missing `template`")?
+ .to_string();
+ let output_dir = table
+ .get("output-dir")
+ .and_then(|v| v.as_str())
+ .ok_or("[[classes]] entry is missing `output-dir`")?
+ .to_string();
+ let description = table
+ .get("description")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ entries.push(ClassEntry {
+ name,
+ template,
+ output_dir,
+ description,
+ });
+ }
+ }
+ Ok(entries)
+}
+
+#[renderer]
+pub fn render_result_class_add(result: ResultClassAdd) -> RenderResult {
+ let mut r = RenderResult::new();
+ println_cargo!(r, "Class added: {}", result.output.display());
+ r
+}
+
+#[renderer]
+pub fn render_error_class_name_required(_err: ErrorClassNameRequired) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "usage: mling class-add <class> <name>");
+ r
+}
+
+#[renderer]
+pub fn render_error_class_config_missing(err: ErrorClassConfigMissing) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "{}", err.info);
+ r
+}
+
+#[renderer]
+pub fn render_error_class_not_found(err: ErrorClassNotFound) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "{}", err.info);
+ r
+}
+
+#[renderer]
+pub fn render_error_class_template_missing(err: ErrorClassTemplateMissing) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "{}", err.info);
+ r
+}
+
+#[renderer]
+pub fn render_error_class_write_failed(err: ErrorClassWriteFailed) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "{}", err.info);
+ r
+}
+
+#[completion(EntryClassAdd)]
+pub fn complete_class_add(ctx: &ShellContext, cwd: &ResCurrentDir) -> Suggest {
+ if ctx.previous_word != "class-add" {
+ return Suggest::file_comp();
+ }
+ let mut suggest = Suggest::new();
+ let Some(project_root) = find_project_root(cwd) else {
+ return suggest;
+ };
+ let classes_path = project_root.join(".mling").join("classes.toml");
+ let Ok(content) = fs::read_to_string(&classes_path) else {
+ return suggest;
+ };
+ let Ok(classes) = parse_classes(&content) else {
+ return suggest;
+ };
+ for entry in classes {
+ if entry.description.is_empty() {
+ suggest.insert(SuggestItem::new(entry.name));
+ } else {
+ suggest.insert(SuggestItem::new_with_desc(entry.name, entry.description));
+ }
+ }
+ suggest
+}
+
+#[metadata(EntryClassAdd)]
+pub fn desc_class_add() -> Description {
+ "Add a class instance to the project from a registered template".into()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn find_project_root_walks_upward() {
+ let tmp = std::env::temp_dir().join(format!("mling-class-test-{}", std::process::id()));
+ let _ = fs::remove_dir_all(&tmp);
+ fs::create_dir_all(tmp.join("project/src/deep/nested")).unwrap();
+ fs::create_dir_all(tmp.join("project/.mling")).unwrap();
+
+ // Found at the project root.
+ // Normalize the expected path the way `find_project_root` does
+ // (canonicalize + strip the Windows verbatim prefix) so the
+ // comparison is immune to Windows 8.3 short-name / long-name
+ // differences and the `\\?\` prefix.
+ let root = deverbatim(&fs::canonicalize(tmp.join("project")).unwrap());
+ assert_eq!(find_project_root(&root), Some(root.clone()));
+
+ // Found by walking up from a deep subdirectory.
+ assert_eq!(find_project_root(&root.join("src/deep/nested")), Some(root));
+
+ // No `.mling` anywhere above.
+ assert_eq!(
+ find_project_root(&tmp.join("project/src").join("..").join("..")),
+ None
+ );
+
+ let _ = fs::remove_dir_all(&tmp);
+ }
+
+ #[test]
+ fn parses_class_entries() {
+ let content = r#"
+[[classes]]
+name = "subcommand"
+template = "classes/subcommand.rs"
+output-dir = "src/command/"
+description = "Add a subcommand"
+
+[[classes]]
+name = "resource"
+template = "classes/resource.rs"
+output-dir = "src/resource/"
+"#;
+ let entries = parse_classes(content).unwrap();
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].name, "subcommand");
+ assert_eq!(entries[0].template, "classes/subcommand.rs");
+ assert_eq!(entries[0].output_dir, "src/command/");
+ assert_eq!(entries[0].description, "Add a subcommand");
+ assert_eq!(entries[1].name, "resource");
+ // Description is optional and defaults to empty.
+ assert_eq!(entries[1].description, "");
+ }
+
+ #[test]
+ fn parses_empty_classes() {
+ let entries = parse_classes("").unwrap();
+ assert!(entries.is_empty());
+ }
+
+ #[test]
+ fn missing_fields_are_rejected() {
+ let content = r#"
+[[classes]]
+name = "subcommand"
+"#;
+ assert!(parse_classes(content).is_err());
+ }
+}
diff --git a/mingling_cli/src/proj_mgr/cmd_proj_init.rs b/mingling_cli/src/proj_mgr/cmd_proj_init.rs
new file mode 100644
index 0000000..1ef8d1e
--- /dev/null
+++ b/mingling_cli/src/proj_mgr/cmd_proj_init.rs
@@ -0,0 +1,365 @@
+use std::{
+ collections::HashMap,
+ fs, io,
+ path::{Path, PathBuf},
+};
+
+use just_fmt::snake_case;
+use just_template::Template;
+use mingling::{
+ Grouped, LazyRes, RenderResult, Routable,
+ macros::{arg, chain, command, metadata, pack, pack_err, r_println, renderer, routeify},
+ metadata::Description,
+ picker::EntryPicker,
+ res::ResCurrentDir,
+};
+
+use crate::{Entry, Next, config::ResMlingConfig, eprintln_cargo, hprintln_cargo, println_cargo};
+
+use super::rule_solver::{
+ eval_rule, parse_checklist, parse_rules, resolve_answers, validate_mutexes,
+};
+use super::template_source::{
+ DEFAULT_TMPL_SOURCE, TemplateSource, cache_dir, normalize_source, resolve_git,
+};
+
+/// The checklist filename that the user edits and is re-read during generation.
+const CHECKLIST_FILENAME: &str = "checklist.toml";
+/// The name of the rules file declaring default params, display blocks and hides.
+const RULE_FILENAME: &str = "rule.toml";
+/// The directory under the project root where the template cache lives.
+const CACHE_DIR_NAME: &str = "tmpl-cache";
+
+pack!(StateProjectGenerate = ());
+pack!(StateProjectChecklistReady = Vec<String>);
+
+/// Result of the checklist phase: the extracted checklist handed to the user.
+#[derive(Debug, Default, Grouped)]
+pub struct ResultProjectChecklistReady {
+ pub checklist: PathBuf,
+}
+
+/// Result of the generate phase: files produced (and hidden) by expansion.
+#[derive(Debug, Default, Grouped)]
+pub struct ResultProjectGenerate {
+ pub generated: Vec<PathBuf>,
+ pub hidden: Vec<PathBuf>,
+}
+
+pack_err!(ErrorTemplateNotProvided = ());
+pack_err!(ErrorTemplateCopyFailed = String);
+pack_err!(ErrorTemplateFetchFailed = String);
+pack_err!(ErrorChecklistMissing = String);
+pack_err!(ErrorRuleParseFailed = String);
+pack_err!(ErrorTemplateExpandFailed = String);
+
+#[command(node = "proj-init", routeify)]
+pub fn proj_init(args: Entry, cwd: &ResCurrentDir) -> Next {
+ // Check if the checklist.toml file exists in the current directory
+ if cwd.join(CHECKLIST_FILENAME).exists() {
+ StateProjectGenerate::new(()).into()
+ } else {
+ StateProjectChecklistReady::new(args.inner).into()
+ }
+}
+
+/// Phase 1: resolve the user-provided template source into
+/// `./.mling/tmpl-cache/` and hand the checklist over for editing.
+#[chain(routeify)]
+pub fn handle_state_proj_checklist_ready(
+ args: StateProjectChecklistReady,
+ cwd: &ResCurrentDir,
+ config: &mut LazyRes<ResMlingConfig>,
+) -> Next {
+ let source: TemplateSource = args
+ .pick_or_route(&arg![TemplateSource], || {
+ ErrorTemplateNotProvided::new(()).to_chain()
+ })
+ .to_result()?;
+
+ // Resolve the template root directory.
+ let template_root: PathBuf = match source {
+ TemplateSource::FsDir(dir) => dir,
+ TemplateSource::Git { reference, variant } => {
+ let configured = config.get_ref().get("tmpl-source");
+ let source_url = normalize_source(if configured.is_empty() {
+ DEFAULT_TMPL_SOURCE
+ } else {
+ configured
+ });
+ resolve_git(&source_url, &reference, &variant, &cache_dir())
+ .map_err(ErrorTemplateFetchFailed::new)?
+ }
+ };
+
+ // Copy the template directory into the .mling directory under the current
+ // directory; create it if it doesn't exist
+ let tmpl_cache = cwd.join(".mling").join(CACHE_DIR_NAME);
+ fs::create_dir_all(&tmpl_cache).map_err(|e| {
+ ErrorTemplateCopyFailed::new(format!("failed to create {}: {e}", tmpl_cache.display()))
+ })?;
+ copy_dir_contents(&template_root, &tmpl_cache)
+ .map_err(|e| ErrorTemplateCopyFailed::new(e.to_string()))?;
+
+ // Move the internal checklist.toml to ./ for the user to fill in
+ let checklist_src = tmpl_cache.join(CHECKLIST_FILENAME);
+ if !checklist_src.is_file() {
+ return ErrorChecklistMissing::new(format!(
+ "no checklist.toml found inside {}",
+ template_root.display()
+ ))
+ .to_chain();
+ }
+ let checklist_dst = cwd.join(CHECKLIST_FILENAME);
+ fs::rename(&checklist_src, &checklist_dst).map_err(|e| {
+ ErrorTemplateCopyFailed::new(format!(
+ "failed to move checklist.toml to {}: {e}",
+ checklist_dst.display()
+ ))
+ })?;
+
+ ResultProjectChecklistReady {
+ checklist: checklist_dst,
+ }
+ .to_chain()
+}
+
+/// Phase 2: expand the cached template with the checklist answers, driven by
+/// `rule.toml` display/hide rules, then clean up the cache.
+#[chain(routeify)]
+pub fn handle_state_project_generate(_: StateProjectGenerate, cwd: &ResCurrentDir) -> Next {
+ let tmpl_cache = cwd.join(".mling").join(CACHE_DIR_NAME);
+ if !tmpl_cache.is_dir() {
+ return ErrorChecklistMissing::new(format!(
+ "template cache not found at {}; run `mling proj-init` with a template directory first",
+ tmpl_cache.display()
+ ))
+ .to_chain();
+ }
+
+ // Read the user-filled checklist.toml
+ let checklist_path = cwd.join(CHECKLIST_FILENAME);
+ let checklist_content = fs::read_to_string(&checklist_path).map_err(|e| {
+ ErrorChecklistMissing::new(format!("failed to read {}: {e}", checklist_path.display()))
+ })?;
+ let answers = parse_checklist(&checklist_content)
+ .map_err(|e| ErrorRuleParseFailed::new(format!("invalid checklist.toml: {e}")))?;
+
+ // Read rule.toml (template rules)
+ let rule_content = fs::read_to_string(tmpl_cache.join(RULE_FILENAME))
+ .map_err(|e| ErrorRuleParseFailed::new(format!("failed to read rule.toml: {e}")))?;
+ let rules = parse_rules(&rule_content)
+ .map_err(|e| ErrorRuleParseFailed::new(format!("invalid rule.toml: {e}")))?;
+
+ // Compute final answers from checklist values + defaults declared in rule.toml
+ let answers = resolve_answers(&answers, &rules);
+
+ // Mutually exclusive toggle groups must not both be enabled.
+ validate_mutexes(&answers, &rules).map_err(ErrorRuleParseFailed::new)?;
+
+ // Derive the crate name from the program name (e.g. `my-cli` -> `my_cli`).
+ let mut params: HashMap<String, String> = answers.clone();
+ if let Some(program_name) = answers.get("program_name") {
+ params.insert("program_crate_name".to_string(), snake_case!(program_name));
+ }
+ for display in &rules.display {
+ if eval_rule(&display.rule, &answers) {
+ params.insert(display.name.clone(), String::new());
+ }
+ }
+
+ // Expand all template entries to the project root
+ let mut generated = Vec::new();
+ expand_tree(&tmpl_cache, cwd, &params, &mut generated, true)
+ .map_err(ErrorTemplateExpandFailed::new)?;
+
+ // hide-file: delete the corresponding generated file when the rule is true
+
+ let mut hidden = Vec::new();
+ for hide in &rules.hide_files {
+ if !eval_rule(&hide.rule, &answers) {
+ continue;
+ }
+ let target = cwd.join(hide.file.trim_start_matches("./"));
+ remove_path(&target).map_err(|e| {
+ ErrorTemplateExpandFailed::new(format!("failed to hide {}: {e}", target.display()))
+ })?;
+ hidden.push(target);
+ }
+
+ // hide-dir: delete the generated directory tree when the rule is true
+ for hide in &rules.hide_dirs {
+ if !eval_rule(&hide.rule, &answers) {
+ continue;
+ }
+ let target = cwd.join(hide.dir.trim_start_matches("./"));
+ remove_path(&target).map_err(|e| {
+ ErrorTemplateExpandFailed::new(format!("failed to hide {}: {e}", target.display()))
+ })?;
+ hidden.push(target);
+ }
+
+ // Clean up the cache
+ fs::remove_dir_all(&tmpl_cache).map_err(|e| {
+ ErrorTemplateExpandFailed::new(format!("failed to remove {}: {e}", tmpl_cache.display()))
+ })?;
+
+ // Project generated; remove the temporary checklist file
+ remove_path(&checklist_path).map_err(|e| {
+ ErrorTemplateExpandFailed::new(format!(
+ "failed to remove {}: {e}",
+ checklist_path.display()
+ ))
+ })?;
+
+ ResultProjectGenerate { generated, hidden }.to_chain()
+}
+
+/// Recursively copy the contents of `src` into `dst`, preserving names.
+fn copy_dir_contents(src: &Path, dst: &Path) -> io::Result<()> {
+ fs::create_dir_all(dst)?;
+ for entry in fs::read_dir(src)? {
+ let entry = entry?;
+ let from = entry.path();
+ let to = dst.join(entry.file_name());
+ if from.is_dir() {
+ copy_dir_contents(&from, &to)?;
+ } else if from.is_file() {
+ fs::copy(&from, &to)?;
+ }
+ }
+ Ok(())
+}
+
+/// Recursively expand the template cache into the project root.
+///
+/// Every file is treated as a `just_template` template and rendered with the
+/// resolved params, except the template metadata files `rule.toml` and
+/// `checklist.toml` at the template root (guarded by `exclude_meta`). Files
+/// and directories keep their names as-is.
+fn expand_tree(
+ src_root: &Path,
+ dst_root: &Path,
+ params: &HashMap<String, String>,
+ generated: &mut Vec<PathBuf>,
+ exclude_meta: bool,
+) -> Result<(), String> {
+ for entry in fs::read_dir(src_root).map_err(|e| e.to_string())? {
+ let entry = entry.map_err(|e| e.to_string())?;
+ let name = entry.file_name().to_string_lossy().into_owned();
+ let src = entry.path();
+
+ // Template metadata files are not part of the generated project.
+ if exclude_meta && (name == RULE_FILENAME || name == CHECKLIST_FILENAME) {
+ continue;
+ }
+
+ let dst = dst_root.join(&name);
+
+ if src.is_dir() {
+ fs::create_dir_all(&dst).map_err(|e| e.to_string())?;
+ expand_tree(&src, &dst, params, generated, false)?;
+ } else if src.is_file() {
+ let content = fs::read_to_string(&src).map_err(|e| e.to_string())?;
+ let mut tmpl = Template::from(content);
+ for (key, value) in params {
+ tmpl.insert_param(key.clone(), value.clone());
+ }
+ let expanded = tmpl
+ .expand()
+ .ok_or_else(|| format!("failed to expand template: {}", src.display()))?;
+ if let Some(parent) = dst.parent() {
+ fs::create_dir_all(parent).map_err(|e| e.to_string())?;
+ }
+ fs::write(&dst, expanded).map_err(|e| e.to_string())?;
+ generated.push(dst);
+ }
+ }
+ Ok(())
+}
+
+/// Remove a file or directory, ignoring "not found".
+fn remove_path(path: &Path) -> io::Result<()> {
+ if path.is_dir() {
+ fs::remove_dir_all(path)
+ } else if path.is_file() {
+ fs::remove_file(path)
+ } else {
+ Ok(())
+ }
+}
+
+#[renderer]
+pub fn render_result_project_checklist_ready(result: ResultProjectChecklistReady) -> RenderResult {
+ let mut r = RenderResult::new();
+ r_println!(r, "Template copied.");
+ r_println!(r, "");
+ hprintln_cargo!(
+ r,
+ "Fill out {} and run `mling proj-init` again to generate the project.",
+ result.checklist.display()
+ );
+ r
+}
+
+#[renderer]
+pub fn render_result_project_generate(result: ResultProjectGenerate) -> RenderResult {
+ let mut r = RenderResult::new();
+ for file in &result.generated {
+ println_cargo!(r, "Generated: {}", file.display());
+ }
+ for file in &result.hidden {
+ println_cargo!(r, "Hidden: {}", file.display());
+ }
+ r
+}
+
+#[renderer]
+pub fn render_error_template_not_provided(_err: ErrorTemplateNotProvided) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(
+ r,
+ "no template directory provided; pass the path to a mingling template directory"
+ );
+ r
+}
+
+#[renderer]
+pub fn render_error_template_copy_failed(err: ErrorTemplateCopyFailed) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "failed to copy template: {}", err.info);
+ r
+}
+
+#[renderer]
+pub fn render_error_template_fetch_failed(err: ErrorTemplateFetchFailed) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "failed to fetch template: {}", err.info);
+ r
+}
+
+#[renderer]
+pub fn render_error_checklist_missing(err: ErrorChecklistMissing) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "{}", err.info);
+ r
+}
+
+#[renderer]
+pub fn render_error_rule_parse_failed(err: ErrorRuleParseFailed) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "{}", err.info);
+ r
+}
+
+#[renderer]
+pub fn render_error_template_expand_failed(err: ErrorTemplateExpandFailed) -> RenderResult {
+ let mut r = RenderResult::new();
+ eprintln_cargo!(r, "{}", err.info);
+ r
+}
+
+#[metadata(EntryProjInit)]
+pub fn desc_proj_init() -> Description {
+ "Guided creation of a Mingling project".into()
+}
diff --git a/mingling_cli/src/proj_mgr/rule_solver.rs b/mingling_cli/src/proj_mgr/rule_solver.rs
new file mode 100644
index 0000000..c749937
--- /dev/null
+++ b/mingling_cli/src/proj_mgr/rule_solver.rs
@@ -0,0 +1,675 @@
+//! Parsing and evaluation of `rule.toml` and `checklist.toml`.
+//!
+//! `rule.toml` drives template generation:
+//! - `[[display]]` entries map a display-block name to a boolean rule; when the
+//! rule evaluates to true, the corresponding `??? >>> NAME` block is enabled.
+//! - `[[hide-file]]` entries map a generated file path to a boolean rule; when
+//! the rule evaluates to true, the generated file is removed.
+//!
+//! `checklist.toml` holds the user's answers as key/value pairs and provides
+//! the variables the rules are evaluated against.
+
+use std::collections::HashMap;
+
+use toml_edit::DocumentMut;
+
+/// A `[[display]]` rule: enable display block `name` when `rule` is true.
+#[derive(Debug, Clone)]
+pub struct DisplayRule {
+ /// Display block name, e.g. `ASYNC`, `PARSER_PICKER`.
+ pub name: String,
+ /// Boolean expression, e.g. `tokio || async_std`.
+ pub rule: String,
+}
+
+/// A `[[hide-file]]` rule: hide `file` when `rule` is true.
+#[derive(Debug, Clone)]
+pub struct HideFileRule {
+ /// File path relative to the project root, e.g. `./build.rs`.
+ pub file: String,
+ /// Boolean expression, e.g. `!completion && !pathf`.
+ pub rule: String,
+}
+
+/// A `[[hide-dir]]` rule: hide the whole directory `dir` when `rule` is true.
+#[derive(Debug, Clone)]
+pub struct HideDirRule {
+ /// Directory path relative to the project root, e.g. `./src/completion/`.
+ pub dir: String,
+ /// Boolean expression, e.g. `!completion`.
+ pub rule: String,
+}
+
+/// A `[[user.*]]` entry describing a checklist answer and its default.
+///
+/// Checklist keys are declared under `[[user.input]]`, `[[user.toggle]]` or
+/// `[[user.selection]]`; toggles and selections may carry a `default` value
+/// that applies when the user leaves the key unset in `checklist.toml`.
+#[derive(Debug, Clone)]
+pub struct UserRule {
+ /// Checklist key name, e.g. `program_name`, `tokio`, `parser`.
+ pub name: String,
+ /// Default value (stringified), if declared in `rule.toml`.
+ pub default: Option<String>,
+}
+
+/// A `[[user.toggle-mutex]]` group: at most one of `mutex` may be enabled.
+#[derive(Debug, Clone)]
+pub struct UserMutex {
+ /// Keys that are mutually exclusive.
+ pub mutex: Vec<String>,
+ /// Human-readable reason shown when the constraint is violated.
+ pub reason: String,
+}
+
+/// All rules parsed from `rule.toml`.
+#[derive(Debug, Default, Clone)]
+pub struct TemplateRules {
+ pub users: Vec<UserRule>,
+ pub mutexes: Vec<UserMutex>,
+ pub display: Vec<DisplayRule>,
+ pub hide_files: Vec<HideFileRule>,
+ pub hide_dirs: Vec<HideDirRule>,
+}
+
+/// Parse `checklist.toml` into a flat map of answers.
+///
+/// Values are stringified: strings keep their content, booleans become
+/// `"true"` / `"false"`, numbers keep their decimal representation.
+/// Commented-out (disabled) keys are ignored by the TOML parser.
+pub fn parse_checklist(content: &str) -> Result<HashMap<String, String>, String> {
+ let doc = content.parse::<DocumentMut>().map_err(|e| e.to_string())?;
+ let mut answers = HashMap::new();
+ for (key, item) in doc.iter() {
+ let toml_edit::Item::Value(value) = item else {
+ continue;
+ };
+ let stringified = match value {
+ toml_edit::Value::String(s) => s.value().clone(),
+ toml_edit::Value::Boolean(b) => b.value().to_string(),
+ toml_edit::Value::Integer(i) => i.value().to_string(),
+ toml_edit::Value::Float(f) => f.value().to_string(),
+ // Arrays, inline tables and datetimes are not valid checklist answers.
+ _ => continue,
+ };
+ answers.insert(key.to_string(), stringified);
+ }
+ Ok(answers)
+}
+
+/// Parse `rule.toml` into display and hide-file rules.
+pub fn parse_rules(content: &str) -> Result<TemplateRules, String> {
+ let doc = content.parse::<DocumentMut>().map_err(|e| e.to_string())?;
+ let mut rules = TemplateRules::default();
+
+ // `[[user.input]]`, `[[user.toggle]]`, `[[user.selection]]` parse into a
+ // `user` table whose keys (`input`/`toggle`/`selection`) hold the arrays.
+ if let Some(user_table) = doc.get("user").and_then(|item| item.as_table()) {
+ for kind in ["input", "toggle", "selection"] {
+ let Some(tables) = user_table
+ .get(kind)
+ .and_then(|item| item.as_array_of_tables())
+ else {
+ continue;
+ };
+ for table in tables {
+ let name = table
+ .get("name")
+ .and_then(|v| v.as_str())
+ .ok_or("[[user.*]] entry is missing `name`")?
+ .to_string();
+ let default =
+ table
+ .get("default")
+ .and_then(|v| v.as_value())
+ .and_then(|v| match v {
+ toml_edit::Value::Boolean(b) => Some(b.value().to_string()),
+ toml_edit::Value::String(s) => Some(s.value().clone()),
+ _ => None,
+ });
+ rules.users.push(UserRule { name, default });
+ }
+ }
+
+ // `[[user.toggle-mutex]]` declares mutually exclusive toggle groups.
+ if let Some(tables) = user_table
+ .get("toggle-mutex")
+ .and_then(|item| item.as_array_of_tables())
+ {
+ for table in tables {
+ let mutex = table
+ .get("mutex")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(str::to_owned))
+ .collect()
+ })
+ .unwrap_or_default();
+ let reason = table
+ .get("reason")
+ .and_then(|v| v.as_str())
+ .unwrap_or("these options are mutually exclusive")
+ .to_string();
+ rules.mutexes.push(UserMutex { mutex, reason });
+ }
+ }
+ }
+
+ if let Some(tables) = doc
+ .get("display")
+ .and_then(|item| item.as_array_of_tables())
+ {
+ for table in tables {
+ let name = table
+ .get("name")
+ .and_then(|v| v.as_str())
+ .ok_or("[[display]] entry is missing `name`")?
+ .to_string();
+ let rule = table
+ .get("rule")
+ .and_then(|v| v.as_str())
+ .ok_or("[[display]] entry is missing `rule`")?
+ .to_string();
+ rules.display.push(DisplayRule { name, rule });
+ }
+ }
+
+ if let Some(tables) = doc
+ .get("hide-file")
+ .and_then(|item| item.as_array_of_tables())
+ {
+ for table in tables {
+ let file = table
+ .get("file")
+ .and_then(|v| v.as_str())
+ .ok_or("[[hide-file]] entry is missing `file`")?
+ .to_string();
+ let rule = table
+ .get("rule")
+ .and_then(|v| v.as_str())
+ .ok_or("[[hide-file]] entry is missing `rule`")?
+ .to_string();
+ rules.hide_files.push(HideFileRule { file, rule });
+ }
+ }
+
+ if let Some(tables) = doc
+ .get("hide-dir")
+ .and_then(|item| item.as_array_of_tables())
+ {
+ for table in tables {
+ let dir = table
+ .get("dir")
+ .and_then(|v| v.as_str())
+ .ok_or("[[hide-dir]] entry is missing `dir`")?
+ .to_string();
+ let rule = table
+ .get("rule")
+ .and_then(|v| v.as_str())
+ .ok_or("[[hide-dir]] entry is missing `rule`")?
+ .to_string();
+ rules.hide_dirs.push(HideDirRule { dir, rule });
+ }
+ }
+
+ Ok(rules)
+}
+
+/// Merge the checklist answers with the defaults declared in `rule.toml`.
+///
+/// A key the user left unset in `checklist.toml` falls back to its declared
+/// default (e.g. `pathf` defaults to `true`); keys the user filled in always
+/// win. The merged map is what rules are evaluated against.
+pub fn resolve_answers(
+ answers: &HashMap<String, String>,
+ rules: &TemplateRules,
+) -> HashMap<String, String> {
+ let mut merged = answers.clone();
+ for user in &rules.users {
+ if let Some(default) = &user.default
+ && !merged.contains_key(&user.name)
+ {
+ merged.insert(user.name.clone(), default.clone());
+ }
+ }
+ merged
+}
+
+/// Validate mutually exclusive toggle groups against the resolved answers.
+///
+/// Returns an error for the first group where more than one key is enabled.
+pub fn validate_mutexes(
+ answers: &HashMap<String, String>,
+ rules: &TemplateRules,
+) -> Result<(), String> {
+ for group in &rules.mutexes {
+ let enabled: Vec<&str> = group
+ .mutex
+ .iter()
+ .filter(|key| is_truthy(key, answers))
+ .map(String::as_str)
+ .collect();
+ if enabled.len() > 1 {
+ return Err(format!(
+ "{} (enabled: {})",
+ group.reason,
+ enabled.join(", ")
+ ));
+ }
+ }
+ Ok(())
+}
+
+/// Evaluate a boolean rule expression against the checklist answers.
+///
+/// Supported syntax:
+/// - bare identifier: `tokio` — true when the key exists with a truthy value
+/// - comparison: `parser == clap`
+/// - operators: `!`, `&&`, `||`
+/// - parentheses for grouping
+pub fn eval_rule(rule: &str, answers: &HashMap<String, String>) -> bool {
+ let mut parser = RuleParser::new(rule, answers);
+ let Some(value) = parser.parse_or() else {
+ return false;
+ };
+ // The whole expression must be consumed; trailing garbage invalidates it.
+ parser.skip_ws();
+ if parser.pos != parser.chars.len() {
+ return false;
+ }
+ value
+}
+
+/// A key is truthy when it is present with a non-empty, non-`"false"` value.
+fn is_truthy(key: &str, answers: &HashMap<String, String>) -> bool {
+ matches!(answers.get(key), Some(value) if !value.is_empty() && value != "false")
+}
+
+/// Recursive-descent parser for `rule.toml` boolean expressions.
+struct RuleParser<'a> {
+ chars: Vec<char>,
+ pos: usize,
+ answers: &'a HashMap<String, String>,
+}
+
+impl<'a> RuleParser<'a> {
+ fn new(rule: &str, answers: &'a HashMap<String, String>) -> Self {
+ Self {
+ chars: rule.chars().collect(),
+ pos: 0,
+ answers,
+ }
+ }
+
+ /// Skips whitespace, then consumes `c` if it matches.
+ fn eat(&mut self, c: char) -> bool {
+ self.skip_ws();
+ if self.chars.get(self.pos) == Some(&c) {
+ self.pos += 1;
+ true
+ } else {
+ false
+ }
+ }
+
+ fn skip_ws(&mut self) {
+ while self.chars.get(self.pos).is_some_and(|c| c.is_whitespace()) {
+ self.pos += 1;
+ }
+ }
+
+ /// `or := and ('||' and)*`
+ fn parse_or(&mut self) -> Option<bool> {
+ let mut value = self.parse_and()?;
+ while self.eat('|') && self.eat('|') {
+ let rhs = self.parse_and()?;
+ value |= rhs;
+ }
+ Some(value)
+ }
+
+ /// `and := unary ('&&' unary)*`
+ fn parse_and(&mut self) -> Option<bool> {
+ let mut value = self.parse_unary()?;
+ while self.eat('&') && self.eat('&') {
+ let rhs = self.parse_unary()?;
+ value &= rhs;
+ }
+ Some(value)
+ }
+
+ /// `unary := '!' unary | primary`
+ fn parse_unary(&mut self) -> Option<bool> {
+ if self.eat('!') {
+ return Some(!self.parse_unary()?);
+ }
+ self.parse_primary()
+ }
+
+ /// `primary := '(' or ')' | ident (('==' | '!=') ident)?`
+ fn parse_primary(&mut self) -> Option<bool> {
+ if self.eat('(') {
+ let value = self.parse_or()?;
+ self.eat(')');
+ return Some(value);
+ }
+ let ident = self.parse_ident()?;
+ if self.eat('=') && self.eat('=') {
+ let other = self.parse_ident()?;
+ return Some(self.answers.get(&ident).map(String::as_str) == Some(other.as_str()));
+ }
+ if self.eat('!') && self.eat('=') {
+ let other = self.parse_ident()?;
+ return Some(self.answers.get(&ident).map(String::as_str) != Some(other.as_str()));
+ }
+ Some(is_truthy(&ident, self.answers))
+ }
+
+ fn parse_ident(&mut self) -> Option<String> {
+ self.skip_ws();
+ let start = self.pos;
+ while self
+ .chars
+ .get(self.pos)
+ .is_some_and(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
+ {
+ self.pos += 1;
+ }
+ if self.pos > start {
+ Some(self.chars[start..self.pos].iter().collect())
+ } else {
+ None
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn checklist_parses_flat_values() {
+ let content = r#"
+program_name = "my-cli"
+parser = "picker"
+exit_code = true
+completion = true
+"#;
+ let answers = parse_checklist(content).unwrap();
+ assert_eq!(answers.get("program_name").unwrap(), "my-cli");
+ assert_eq!(answers.get("parser").unwrap(), "picker");
+ assert_eq!(answers.get("exit_code").unwrap(), "true");
+ assert_eq!(answers.get("completion").unwrap(), "true");
+ }
+
+ #[test]
+ fn checklist_ignores_commented_keys() {
+ let content = r#"
+# tokio = true
+completion = true
+"#;
+ let answers = parse_checklist(content).unwrap();
+ assert!(!answers.contains_key("tokio"));
+ assert!(answers.contains_key("completion"));
+ }
+
+ #[test]
+ fn rules_parse_display_and_hide_file() {
+ let content = r#"
+[[display]]
+name = "TOKIO"
+rule = "tokio"
+
+[[display]]
+name = "ASYNC"
+rule = "tokio || async_std"
+
+[[display]]
+name = "PARSER_PICKER"
+rule = "use_parser && parser == picker"
+
+[[hide-file]]
+file = "./build.rs"
+rule = "!completion && !pathf"
+"#;
+ let rules = parse_rules(content).unwrap();
+ assert_eq!(rules.display.len(), 3);
+ assert_eq!(rules.display[0].name, "TOKIO");
+ assert_eq!(rules.display[0].rule, "tokio");
+ assert_eq!(rules.display[1].rule, "tokio || async_std");
+ assert_eq!(rules.display[2].rule, "use_parser && parser == picker");
+ assert_eq!(rules.hide_files.len(), 1);
+ assert_eq!(rules.hide_files[0].file, "./build.rs");
+ assert_eq!(rules.hide_files[0].rule, "!completion && !pathf");
+ }
+
+ #[test]
+ fn rules_parse_user_defaults() {
+ let content = r#"
+[[user.toggle]]
+name = "pathf"
+default = true
+
+[[user.toggle]]
+name = "tokio"
+
+[[user.selection]]
+name = "parser"
+option = [ "clap", "builtin", "picker" ]
+"#;
+ let rules = parse_rules(content).unwrap();
+ assert_eq!(rules.users.len(), 3);
+ assert_eq!(rules.users[0].name, "pathf");
+ assert_eq!(rules.users[0].default.as_deref(), Some("true"));
+ assert_eq!(rules.users[1].name, "tokio");
+ assert_eq!(rules.users[1].default, None);
+ assert_eq!(rules.users[2].name, "parser");
+ assert_eq!(rules.users[2].default, None);
+ }
+
+ #[test]
+ fn resolve_answers_applies_declared_defaults() {
+ let content = r#"
+[[user.toggle]]
+name = "pathf"
+default = true
+
+[[user.toggle]]
+name = "completion"
+"#;
+ let rules = parse_rules(content).unwrap();
+
+ // completion answered, pathf left unset -> pathf falls back to true
+ let mut answers = HashMap::new();
+ answers.insert("completion".into(), "true".into());
+ let merged = resolve_answers(&answers, &rules);
+ assert_eq!(merged.get("completion").unwrap(), "true");
+ assert_eq!(merged.get("pathf").unwrap(), "true");
+
+ // user-provided value always wins over the default
+ let mut answers = HashMap::new();
+ answers.insert("pathf".into(), "false".into());
+ let merged = resolve_answers(&answers, &rules);
+ assert_eq!(merged.get("pathf").unwrap(), "false");
+ }
+
+ #[test]
+ fn rules_parse_hide_dir() {
+ let content = r#"
+[[hide-dir]]
+dir = "./src/completion/"
+rule = "!completion"
+
+[[hide-dir]]
+dir = "./src/dispatch/"
+rule = "!dispatch_tree"
+"#;
+ let rules = parse_rules(content).unwrap();
+ assert_eq!(rules.hide_dirs.len(), 2);
+ assert_eq!(rules.hide_dirs[0].dir, "./src/completion/");
+ assert_eq!(rules.hide_dirs[0].rule, "!completion");
+ assert_eq!(rules.hide_dirs[1].dir, "./src/dispatch/");
+ }
+
+ #[test]
+ fn rules_parse_toggle_mutex() {
+ let content = r#"
+[[user.toggle-mutex]]
+mutex = [ "tokio", "async_std", "smol" ]
+reason = "You can only select one async runtime"
+"#;
+ let rules = parse_rules(content).unwrap();
+ assert_eq!(rules.mutexes.len(), 1);
+ assert_eq!(rules.mutexes[0].mutex, vec!["tokio", "async_std", "smol"]);
+ assert_eq!(
+ rules.mutexes[0].reason,
+ "You can only select one async runtime"
+ );
+ }
+
+ #[test]
+ fn validate_mutexes_allows_zero_or_one() {
+ let content = r#"
+[[user.toggle-mutex]]
+mutex = [ "tokio", "async_std" ]
+reason = "one async runtime only"
+"#;
+ let rules = parse_rules(content).unwrap();
+
+ // None enabled.
+ assert!(validate_mutexes(&HashMap::new(), &rules).is_ok());
+
+ // Exactly one enabled.
+ let mut answers = HashMap::new();
+ answers.insert("tokio".into(), "true".into());
+ assert!(validate_mutexes(&answers, &rules).is_ok());
+ }
+
+ #[test]
+ fn validate_mutexes_rejects_multiple_enabled() {
+ let content = r#"
+[[user.toggle-mutex]]
+mutex = [ "tokio", "async_std" ]
+reason = "one async runtime only"
+"#;
+ let rules = parse_rules(content).unwrap();
+
+ let mut answers = HashMap::new();
+ answers.insert("tokio".into(), "true".into());
+ answers.insert("async_std".into(), "true".into());
+ let err = validate_mutexes(&answers, &rules).unwrap_err();
+ assert!(err.contains("one async runtime only"), "unexpected: {err}");
+ assert!(err.contains("tokio") && err.contains("async_std"));
+ }
+
+ #[test]
+ fn toggle_semantics_match_checklist_states() {
+ // rule.toml: `pathf` defaults to true, `tokio` has no default.
+ let content = r#"
+[[user.toggle]]
+name = "pathf"
+default = true
+
+[[user.toggle]]
+name = "tokio"
+"#;
+ let rules = parse_rules(content).unwrap();
+
+ // 1. Commented out (`# key = true`): key absent -> default applies;
+ // keys without a default stay absent (falsy).
+ let merged = resolve_answers(&HashMap::new(), &rules);
+ assert_eq!(merged.get("pathf").unwrap(), "true");
+ assert!(!merged.contains_key("tokio"));
+ assert!(eval_rule("pathf", &merged));
+ assert!(!eval_rule("tokio", &merged));
+
+ // 2. Explicit `key = true`.
+ let mut answers = HashMap::new();
+ answers.insert("pathf".into(), "true".into());
+ answers.insert("tokio".into(), "true".into());
+ let merged = resolve_answers(&answers, &rules);
+ assert!(eval_rule("pathf", &merged));
+ assert!(eval_rule("tokio", &merged));
+
+ // 3. Explicit `key = false` overrides the declared default.
+ let mut answers = HashMap::new();
+ answers.insert("pathf".into(), "false".into());
+ let merged = resolve_answers(&answers, &rules);
+ assert_eq!(merged.get("pathf").unwrap(), "false");
+ assert!(!eval_rule("pathf", &merged));
+ }
+
+ #[test]
+ fn eval_bare_identifier() {
+ let mut answers = HashMap::new();
+ answers.insert("tokio".into(), "true".into());
+ assert!(eval_rule("tokio", &answers));
+ assert!(!eval_rule("async_std", &answers));
+ }
+
+ #[test]
+ fn eval_false_value_is_falsy() {
+ let mut answers = HashMap::new();
+ answers.insert("pathf".into(), "false".into());
+ assert!(!eval_rule("pathf", &answers));
+
+ // Empty string is also falsy.
+ answers.insert("empty".into(), "".into());
+ assert!(!eval_rule("empty", &answers));
+ }
+
+ #[test]
+ fn eval_boolean_operators() {
+ let mut answers = HashMap::new();
+ answers.insert("tokio".into(), "true".into());
+ answers.insert("completion".into(), "true".into());
+ answers.insert("pathf".into(), "false".into());
+
+ assert!(eval_rule("tokio || async_std", &answers));
+ assert!(!eval_rule("async_std && tokio", &answers));
+ assert!(eval_rule("!async_std", &answers));
+ // completion is true, so `!completion && !pathf` is false
+ assert!(!eval_rule("!completion && !pathf", &answers));
+ // with neither key present the same rule becomes true
+ assert!(eval_rule("!completion && !pathf", &HashMap::new()));
+ assert!(eval_rule("(tokio || async_std) && completion", &answers));
+ assert!(!eval_rule("(async_std || tokio) && !completion", &answers));
+ }
+
+ #[test]
+ fn eval_equality_comparison() {
+ let mut answers = HashMap::new();
+ answers.insert("parser".into(), "picker".into());
+ answers.insert("use_parser".into(), "true".into());
+
+ assert!(eval_rule("parser == picker", &answers));
+ assert!(!eval_rule("parser == clap", &answers));
+ assert!(eval_rule("use_parser && parser == picker", &answers));
+ }
+
+ #[test]
+ fn eval_not_equal_comparison() {
+ let mut answers = HashMap::new();
+ answers.insert("parser".into(), "picker".into());
+ answers.insert("use_parser".into(), "true".into());
+
+ assert!(!eval_rule("parser != picker", &answers));
+ assert!(eval_rule("parser != clap", &answers));
+
+ // The template's NOT_PARSER_PICKER rule.
+ assert!(!eval_rule("!use_parser || parser != picker", &answers));
+ answers.remove("use_parser");
+ assert!(eval_rule("!use_parser || parser != picker", &answers));
+ }
+
+ #[test]
+ fn eval_rejects_trailing_garbage() {
+ // Unsupported tokens must invalidate the expression instead of being
+ // silently ignored.
+ let mut answers = HashMap::new();
+ answers.insert("parser".into(), "picker".into());
+ assert!(!eval_rule("parser >>> picker", &answers));
+ assert!(!eval_rule("parser ||", &answers));
+ }
+}
diff --git a/mingling_cli/src/proj_mgr/template_source.rs b/mingling_cli/src/proj_mgr/template_source.rs
new file mode 100644
index 0000000..6364512
--- /dev/null
+++ b/mingling_cli/src/proj_mgr/template_source.rs
@@ -0,0 +1,365 @@
+//! Template source resolution for `proj-init`.
+//!
+//! A template source is either:
+//! - a git remote template addressed as `<ref>@<variant>` (e.g. `0.4@basic`),
+//! where `ref` is a tag, branch or commit hash and `variant` is a
+//! subdirectory of the repository;
+//! - a local template directory given by a plain path.
+
+use std::{
+ fs,
+ path::{Path, PathBuf},
+ process::Command,
+};
+
+use mingling::picker::{PickerArgResult, SinglePickable};
+
+/// Default template source when none is configured: the `mingling-rs/tmpl`
+/// repository on GitHub.
+pub const DEFAULT_TMPL_SOURCE: &str = "mingling-rs/tmpl";
+
+/// The template cache root: `~/.local/share/mingling/cache`.
+pub fn cache_dir() -> PathBuf {
+ dirs::data_local_dir()
+ .unwrap_or_default()
+ .join("mingling")
+ .join("cache")
+}
+
+/// Normalize a template source spec into a full git URL.
+///
+/// - `mingling-rs/tmpl` -> `https://github.com/mingling-rs/tmpl.git`
+/// - `https://github.com/mingling-rs/tmpl` -> `https://github.com/mingling-rs/tmpl.git`
+/// - `https://example.com/tmpl.git` -> unchanged
+pub fn normalize_source(source: &str) -> String {
+ if source.ends_with(".git") {
+ return source.to_string();
+ }
+ if source.contains("://") {
+ // Ensure GitHub URLs share the same cache key as the short form.
+ if let Some(rest) = source.strip_prefix("https://github.com/") {
+ return format!("https://github.com/{rest}.git");
+ }
+ return source.to_string();
+ }
+ if let Some((owner, repo)) = source.split_once('/') {
+ return format!("https://github.com/{owner}/{repo}.git");
+ }
+ source.to_string()
+}
+
+/// A template source provided by the user.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum TemplateSource {
+ /// Git remote template: `<ref>@<variant>`.
+ Git { reference: String, variant: String },
+ /// Local template directory.
+ FsDir(PathBuf),
+}
+
+impl TemplateSource {
+ /// Creates a git source from a `ref@variant` spec.
+ pub fn git(reference: impl Into<String>, variant: impl Into<String>) -> Self {
+ Self::Git {
+ reference: reference.into(),
+ variant: variant.into(),
+ }
+ }
+
+ /// Creates a local-directory source.
+ pub fn fs_dir(path: impl Into<PathBuf>) -> Self {
+ Self::FsDir(path.into())
+ }
+}
+
+impl SinglePickable for TemplateSource {
+ fn pick_single(str: Option<&str>) -> PickerArgResult<Self> {
+ let Some(raw) = str else {
+ return PickerArgResult::NotFound;
+ };
+
+ // `<ref>@<variant>` — both sides non-empty.
+ if let Some((reference, variant)) = raw.split_once('@')
+ && !reference.is_empty()
+ && !variant.is_empty()
+ {
+ return PickerArgResult::Parsed(Self::git(reference, variant));
+ }
+
+ // Plain path — reuse the PathBuf parsing (handles `~` expansion etc.).
+ match <PathBuf as SinglePickable>::pick_single(str) {
+ PickerArgResult::Parsed(path) => PickerArgResult::Parsed(Self::FsDir(path)),
+ PickerArgResult::NotFound => PickerArgResult::NotFound,
+ PickerArgResult::Unparsed => PickerArgResult::Unparsed,
+ }
+ }
+}
+
+/// Resolve a git template source to a local template directory.
+///
+/// The repository is shallow-cloned into
+/// `<cache>/<source-hash>/<ref-hash>/` where `<source-hash>` is the first 16
+/// hex chars of the SHA-256 of the source URL and `<ref-hash>` is the first 16
+/// hex chars of the resolved commit. Already-cached references are reused. The
+/// returned path is `<repo>/<variant>`, validated to contain a `checklist.toml`.
+pub fn resolve_git(
+ source_url: &str,
+ reference: &str,
+ variant: &str,
+ cache: &Path,
+) -> Result<PathBuf, String> {
+ let source_hash = sha256_prefix16(source_url);
+ let full_hash = resolve_commit(source_url, reference)?;
+ let ref_hash = &full_hash[..16];
+ let repo_dir = cache.join(source_hash).join(ref_hash);
+
+ if !repo_dir.join(".git").is_dir() {
+ shallow_clone(source_url, reference, &full_hash, &repo_dir)?;
+ }
+
+ let template_dir = repo_dir.join(variant);
+ if !template_dir.join("checklist.toml").is_file() {
+ return Err(format!(
+ "variant `{variant}` has no checklist.toml in repository {source_url}"
+ ));
+ }
+ Ok(template_dir)
+}
+
+/// First 16 hex chars of the SHA-256 digest of `input`.
+fn sha256_prefix16(input: &str) -> String {
+ use sha2::{Digest, Sha256};
+ let mut hasher = Sha256::new();
+ hasher.update(input.as_bytes());
+ let digest = hasher.finalize();
+ digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
+}
+
+/// Resolve `reference` (tag / branch / commit hash) to its full commit hash.
+///
+/// A full 40-hex reference is used as-is; otherwise `git ls-remote` resolves
+/// it — first as a ref name (tag / branch), then by prefix-matching against
+/// every advertised ref to support abbreviated commit hashes.
+fn resolve_commit(source_url: &str, reference: &str) -> Result<String, String> {
+ if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
+ return Ok(reference.to_string());
+ }
+
+ // Ref name resolution (tag / branch).
+ let by_ref = git_ls_remote(source_url, Some(reference))?;
+ if let Some(hash) = by_ref
+ .lines()
+ .next()
+ .and_then(|line| line.split('\t').next())
+ {
+ return Ok(hash.to_string());
+ }
+
+ // Abbreviated commit hash: prefix-match against all advertised refs.
+ let all_refs = git_ls_remote(source_url, None)?;
+ for line in all_refs.lines() {
+ let Some(hash) = line.split('\t').next() else {
+ continue;
+ };
+ if hash.starts_with(reference) {
+ return Ok(hash.to_string());
+ }
+ }
+
+ Err(format!("reference `{reference}` not found in {source_url}"))
+}
+
+/// Run `git ls-remote <url> [pattern]` and return its stdout.
+fn git_ls_remote(source_url: &str, pattern: Option<&str>) -> Result<String, String> {
+ let mut cmd = Command::new("git");
+ cmd.args(["ls-remote", source_url]);
+ if let Some(pattern) = pattern {
+ cmd.arg(pattern);
+ }
+ let output = cmd
+ .output()
+ .map_err(|e| format!("failed to run `git ls-remote`: {e}"))?;
+ if !output.status.success() {
+ return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
+ }
+ Ok(String::from_utf8_lossy(&output.stdout).into_owned())
+}
+
+/// Shallow-clone `reference` from `source_url` into `dst`.
+///
+/// Uses `git init` + `git fetch --depth 1 origin <reference>` so that tags and
+/// branches are handled uniformly. When the reference is not a ref name (a
+/// commit hash), it falls back to fetching the resolved full hash — supported
+/// by GitHub, but not by plain local `file://` protocols.
+fn shallow_clone(
+ source_url: &str,
+ reference: &str,
+ full_hash: &str,
+ dst: &Path,
+) -> Result<(), String> {
+ if dst.exists() {
+ fs::remove_dir_all(dst).map_err(|e| e.to_string())?;
+ }
+ fs::create_dir_all(dst).map_err(|e| e.to_string())?;
+
+ run_git(dst, ["init", "-q"])?;
+ run_git(dst, ["remote", "add", "origin", source_url])?;
+
+ let fetched_by_ref = run_git(dst, ["fetch", "-q", "--depth", "1", "origin", reference]);
+ if fetched_by_ref.is_err() {
+ run_git(dst, ["fetch", "-q", "--depth", "1", "origin", full_hash])?;
+ }
+
+ // `git fetch` only writes FETCH_HEAD; the fresh repo has no branch yet, so
+ // create one explicitly to populate the working tree.
+ run_git(dst, ["checkout", "-q", "-B", "cache", "FETCH_HEAD"])?;
+ Ok(())
+}
+
+/// Run a git command in `cwd`, returning an error message on failure.
+fn run_git<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<(), String> {
+ let status = Command::new("git")
+ .args(args)
+ .current_dir(cwd)
+ .status()
+ .map_err(|e| format!("failed to run git: {e}"))?;
+ if !status.success() {
+ return Err(format!("git command failed with {status}"));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Create a local git repository at `dir` with one commit tagged `v0.1`
+ /// and a `basic` variant subdirectory containing a checklist.
+ fn make_repo(dir: &Path) {
+ fs::create_dir_all(dir).unwrap();
+ run_git(dir, ["init", "-q", "-b", "main"]).unwrap();
+ fs::create_dir_all(dir.join("basic")).unwrap();
+ fs::write(
+ dir.join("basic").join("checklist.toml"),
+ "program_name = \"x\"\n",
+ )
+ .unwrap();
+ fs::write(dir.join("basic").join("rule.toml"), "").unwrap();
+ run_git(dir, ["add", "."]).unwrap();
+ run_git(
+ dir,
+ [
+ "-c",
+ "user.name=t",
+ "-c",
+ "user.email=t@t",
+ "commit",
+ "-q",
+ "-m",
+ "init",
+ ],
+ )
+ .unwrap();
+ run_git(dir, ["tag", "v0.1"]).unwrap();
+ }
+
+ #[test]
+ fn parses_ref_at_variant_as_git() {
+ let source = TemplateSource::pick_single(Some("0.4@basic")).unwrap();
+ assert_eq!(source, TemplateSource::git("0.4", "basic"));
+ }
+
+ #[test]
+ fn parses_plain_path_as_fs_dir() {
+ let source = TemplateSource::pick_single(Some("/some/dir")).unwrap();
+ assert_eq!(source, TemplateSource::FsDir(PathBuf::from("/some/dir")));
+ }
+
+ #[test]
+ fn missing_input_is_not_found() {
+ assert!(matches!(
+ TemplateSource::pick_single(None),
+ PickerArgResult::NotFound
+ ));
+ }
+
+ #[test]
+ fn empty_variant_falls_back_to_path() {
+ // `ref@` has an empty variant — treat as a path, not a git source.
+ let source = TemplateSource::pick_single(Some("0.4@")).unwrap();
+ assert_eq!(source, TemplateSource::FsDir(PathBuf::from("0.4@")));
+ }
+
+ #[test]
+ fn sha256_prefix_is_stable_and_16_chars() {
+ let a = sha256_prefix16("https://example.com/repo.git");
+ let b = sha256_prefix16("https://example.com/repo.git");
+ let c = sha256_prefix16("https://example.com/other.git");
+ assert_eq!(a, b);
+ assert_eq!(a.len(), 16);
+ assert_ne!(a, c);
+ }
+
+ #[test]
+ fn normalize_source_default_and_configured() {
+ assert_eq!(
+ normalize_source(DEFAULT_TMPL_SOURCE),
+ "https://github.com/mingling-rs/tmpl.git"
+ );
+ assert_eq!(
+ normalize_source("https://example.com/tmpl.git"),
+ "https://example.com/tmpl.git"
+ );
+ assert_eq!(
+ normalize_source("some-one/other-tmpl"),
+ "https://github.com/some-one/other-tmpl.git"
+ );
+ // GitHub URL without `.git` shares the cache key with the short form.
+ assert_eq!(
+ normalize_source("https://github.com/mingling-rs/tmpl"),
+ "https://github.com/mingling-rs/tmpl.git"
+ );
+ }
+
+ #[test]
+ fn resolve_git_clones_tag_and_validates_variant() {
+ let tmp =
+ std::env::temp_dir().join(format!("mling-tmpl-src-test-{}-clone", std::process::id()));
+ let repo = tmp.join("repo");
+ let cache = tmp.join("cache");
+ let _ = fs::remove_dir_all(&tmp);
+ make_repo(&repo);
+
+ let repo_url = format!("file://{}", repo.display());
+ let template = resolve_git(&repo_url, "v0.1", "basic", &cache).unwrap();
+ assert!(template.join("checklist.toml").is_file());
+
+ // Cached under <source-hash>/<ref-hash> with a working tree.
+ let source_hash = sha256_prefix16(&repo_url);
+ let full_hash = resolve_commit(&repo_url, "v0.1").unwrap();
+ let cached = cache.join(source_hash).join(&full_hash[..16]);
+ assert!(cached.join(".git").is_dir());
+
+ // Second resolution reuses the cache.
+ let template_again = resolve_git(&repo_url, "v0.1", "basic", &cache).unwrap();
+ assert_eq!(template, template_again);
+
+ let _ = fs::remove_dir_all(&tmp);
+ }
+
+ #[test]
+ fn resolve_git_rejects_unknown_variant() {
+ let tmp =
+ std::env::temp_dir().join(format!("mling-tmpl-src-test-{}-reject", std::process::id()));
+ let repo = tmp.join("repo");
+ let cache = tmp.join("cache");
+ let _ = fs::remove_dir_all(&tmp);
+ make_repo(&repo);
+
+ let repo_url = format!("file://{}", repo.display());
+ let err = resolve_git(&repo_url, "v0.1", "nope", &cache).unwrap_err();
+ assert!(err.contains("nope"), "unexpected error: {err}");
+
+ let _ = fs::remove_dir_all(&tmp);
+ }
+}