diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-08-09 13:52:26 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-08-09 13:52:26 +0800 |
| commit | 3b5da73b9b5401d426bae20a6bd735dab26b6b8e (patch) | |
| tree | f9960e3c3213e44f9f023c5cb7867fb891ca3b1c /mingling_cli/src/proj_mgr | |
| parent | 76e42ad0b79d012581629268879620261575d1a1 (diff) | |
feat: add project template expansion with rule-based generation
Implement `mling proj-init` to generate projects from zip templates with
checklist-based configuration. Extracts template archive, moves
checklist for user editing, and expands `tmpl_`-prefixed files using
`just_template` with boolean rule evaluation from `rule.toml` for file
hides and display toggles.
Diffstat (limited to 'mingling_cli/src/proj_mgr')
| -rw-r--r-- | mingling_cli/src/proj_mgr/cmd_proj_init.rs | 356 | ||||
| -rw-r--r-- | mingling_cli/src/proj_mgr/rule_solver.rs | 481 |
2 files changed, 837 insertions, 0 deletions
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..3dfad9c --- /dev/null +++ b/mingling_cli/src/proj_mgr/cmd_proj_init.rs @@ -0,0 +1,356 @@ +use std::{ + collections::HashMap, + fs::{self, File}, + io, + path::{Path, PathBuf}, +}; + +use just_template::Template; +use mingling::{ + Grouped, RenderResult, Routable, + macros::{arg, chain, command, metadata, pack, pack_err, r_println, renderer, routeify}, + metadata::Description, + picker::{EntryPicker, value::FilePath}, + res::ResCurrentDir, +}; + +use crate::{Entry, Next, eprintln_cargo, hprintln_cargo, println_cargo}; + +use super::rule_solver::{eval_rule, parse_checklist, parse_rules, resolve_answers}; + +/// The name of the checklist file the user edits and we re-read on generate. +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"; +/// The internal `.mling` directory name inside the template archive (with `tmpl_` prefix). +const TEMPLATE_MLING_DIR: &str = "tmpl_.mling"; +/// Prefix marking directories/files that participate in expansion. +const TMPL_PREFIX: &str = "tmpl_"; + +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!(ErrorTemplateZipNotProvided = ()); +pack_err!(ErrorUnzipFailed = 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: extract the user-provided template archive into +/// `./.mling/tmpl-cache/` and hand the checklist over for editing. +#[chain(routeify)] +pub fn handle_state_proj_checklist_ready( + args: StateProjectChecklistReady, + cwd: &ResCurrentDir, +) -> Next { + let tmpl_file: Option<FilePath> = args.pick(&arg![Option<FilePath>]).to_result()?; + + // Validate + let Some(tmpl_file) = tmpl_file else { + return ErrorTemplateZipNotProvided::new(()).to_chain(); + }; + + // Extract the file 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| { + ErrorUnzipFailed::new(format!("failed to create {}: {e}", tmpl_cache.display())) + })?; + unzip_to(&tmpl_file, &tmpl_cache).map_err(ErrorUnzipFailed::new)?; + + // 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 {}", + tmpl_file.display() + )) + .to_chain(); + } + let checklist_dst = cwd.join(CHECKLIST_FILENAME); + fs::rename(&checklist_src, &checklist_dst).map_err(|e| { + ErrorUnzipFailed::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 archive 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); + + // Compute display block toggles based on rules: checklist values + rules whose display condition is true + + let mut params: HashMap<String, String> = answers.clone(); + for display in &rules.display { + if eval_rule(&display.rule, &answers) { + params.insert(display.name.clone(), String::new()); + } + } + + // Expand all tmpl_* entries to the project root + let mut generated = Vec::new(); + expand_tree(&tmpl_cache, cwd, ¶ms, &mut generated) + .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); + } + + // 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() +} + +/// Extract a ZIP archive into `dest`, guarding against path traversal. +fn unzip_to(zip_path: &Path, dest: &Path) -> Result<(), String> { + let file = File::open(zip_path).map_err(|e| e.to_string())?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?; + + for i in 0..archive.len() { + let mut entry = archive.by_index(i).map_err(|e| e.to_string())?; + let Some(rel) = entry.enclosed_name() else { + return Err(format!("illegal path in archive: {}", entry.name())); + }; + let out_path = dest.join(rel); + if entry.is_dir() { + fs::create_dir_all(&out_path).map_err(|e| e.to_string())?; + } else { + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let mut out = File::create(&out_path).map_err(|e| e.to_string())?; + io::copy(&mut entry, &mut out).map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +/// Recursively expand the template cache into the project root. +/// +/// Every path component with a `tmpl_` prefix is stripped for the target path +/// (`tmpl_src/tmpl_main.rs` -> `src/main.rs`). `tmpl_`-prefixed files are +/// rendered through `just_template` with the resolved params; other files are +/// skipped. `tmpl_.mling` is copied into `./.mling` without rendering. +fn expand_tree( + src_root: &Path, + dst_root: &Path, + params: &HashMap<String, String>, + generated: &mut Vec<PathBuf>, +) -> 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(); + + // The template's own .mling tree is copied verbatim (minus `tmpl_` prefixes). + if name == TEMPLATE_MLING_DIR && src.is_dir() { + copy_tree(&src, &dst_root.join(".mling")).map_err(|e| e.to_string())?; + continue; + } + + let target_name = name + .strip_prefix(TMPL_PREFIX) + .map(str::to_owned) + .unwrap_or(name.clone()); + let dst = dst_root.join(&target_name); + + if src.is_dir() { + fs::create_dir_all(&dst).map_err(|e| e.to_string())?; + expand_tree(&src, &dst, params, generated)?; + } else if src.is_file() && name.starts_with(TMPL_PREFIX) { + 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(()) +} + +/// Copy a directory tree into `dst`, stripping the `tmpl_` prefix from every +/// component name (e.g. `tmpl_command` -> `command`). +fn copy_tree(src: &Path, dst: &Path) -> io::Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + let target_name = name + .strip_prefix(TMPL_PREFIX) + .map(str::to_owned) + .unwrap_or(name); + let from = entry.path(); + let to = dst.join(&target_name); + if from.is_dir() { + copy_tree(&from, &to)?; + } else if from.is_file() { + fs::copy(&from, &to)?; + } + } + 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 extracted."); + 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_zip_not_provided(_err: ErrorTemplateZipNotProvided) -> RenderResult { + let mut r = RenderResult::new(); + eprintln_cargo!( + r, + "no template archive provided; pass the path to a mingling template zip file" + ); + r +} + +#[renderer] +pub fn render_error_unzip_failed(err: ErrorUnzipFailed) -> RenderResult { + let mut r = RenderResult::new(); + eprintln_cargo!(r, "failed to extract 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..0e00458 --- /dev/null +++ b/mingling_cli/src/proj_mgr/rule_solver.rs @@ -0,0 +1,481 @@ +//! 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 `[[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>, +} + +/// All rules parsed from `rule.toml`. +#[derive(Debug, Default, Clone)] +pub struct TemplateRules { + pub users: Vec<UserRule>, + pub display: Vec<DisplayRule>, + pub hide_files: Vec<HideFileRule>, +} + +/// 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 }); + } + } + } + + 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 }); + } + } + + 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 +} + +/// 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); + parser.parse_or().unwrap_or(false) +} + +/// 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())); + } + 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 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)); + } +} |
