diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-08-09 18:45:47 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-08-09 18:46:17 +0800 |
| commit | c6f695bf5e757fdae8115abfcefb547745cd37be (patch) | |
| tree | cb8ebbd1e99b2a46ceab44237e769c83f452927e /mingling_cli/src/proj_mgr | |
| parent | a05379b0dcf8b53fdb644f4dd803c46a1d8325fb (diff) | |
feat(proj_mgr): add toggle mutex validation and program crate name
Support `[[user.toggle-mutex]]` groups in rule.toml to enforce
mutually exclusive toggle options, and derive `program_crate_name`
from `program_name` using snake_case conversion.
Diffstat (limited to 'mingling_cli/src/proj_mgr')
| -rw-r--r-- | mingling_cli/src/proj_mgr/cmd_proj_init.rs | 12 | ||||
| -rw-r--r-- | mingling_cli/src/proj_mgr/rule_solver.rs | 107 |
2 files changed, 117 insertions, 2 deletions
diff --git a/mingling_cli/src/proj_mgr/cmd_proj_init.rs b/mingling_cli/src/proj_mgr/cmd_proj_init.rs index 15f1f50..6e3908b 100644 --- a/mingling_cli/src/proj_mgr/cmd_proj_init.rs +++ b/mingling_cli/src/proj_mgr/cmd_proj_init.rs @@ -4,6 +4,7 @@ use std::{ path::{Path, PathBuf}, }; +use just_fmt::snake_case; use just_template::Template; use mingling::{ Grouped, LazyRes, RenderResult, Routable, @@ -15,7 +16,9 @@ use mingling::{ use crate::{Entry, Next, config::ResMlingConfig, eprintln_cargo, hprintln_cargo, println_cargo}; -use super::rule_solver::{eval_rule, parse_checklist, parse_rules, resolve_answers}; +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, }; @@ -151,9 +154,14 @@ pub fn handle_state_project_generate(_: StateProjectGenerate, cwd: &ResCurrentDi // 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 + // 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()); diff --git a/mingling_cli/src/proj_mgr/rule_solver.rs b/mingling_cli/src/proj_mgr/rule_solver.rs index 0e00458..7dfb7c7 100644 --- a/mingling_cli/src/proj_mgr/rule_solver.rs +++ b/mingling_cli/src/proj_mgr/rule_solver.rs @@ -44,10 +44,20 @@ pub struct UserRule { 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>, } @@ -110,6 +120,30 @@ pub fn parse_rules(content: &str) -> Result<TemplateRules, String> { 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 @@ -173,6 +207,31 @@ pub fn resolve_answers( 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: @@ -395,6 +454,54 @@ name = "completion" } #[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#" |
