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_proj_init.rs12
-rw-r--r--mingling_cli/src/proj_mgr/rule_solver.rs107
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#"