diff options
Diffstat (limited to 'mingling_cli/src/proj_mgr')
| -rw-r--r-- | mingling_cli/src/proj_mgr/cmd_class_add.rs | 344 | ||||
| -rw-r--r-- | mingling_cli/src/proj_mgr/cmd_proj_init.rs | 365 | ||||
| -rw-r--r-- | mingling_cli/src/proj_mgr/rule_solver.rs | 675 | ||||
| -rw-r--r-- | mingling_cli/src/proj_mgr/template_source.rs | 365 |
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, ¶ms, &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); + } +} |
