aboutsummaryrefslogtreecommitdiff
path: root/mingling_cli/src/proj_mgr/rule_solver.rs
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_cli/src/proj_mgr/rule_solver.rs')
-rw-r--r--mingling_cli/src/proj_mgr/rule_solver.rs481
1 files changed, 481 insertions, 0 deletions
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));
+ }
+}