Skip to main content

whycodes_config/
stream_rules.rs

1//! Stream rules from `.whycodes/rules/*.md`.
2//!
3//! A rule file whose YAML frontmatter names a `pattern` is a stream rule:
4//! optional `name` (default: file stem), `scope`, `tools`, `repeat`; the body
5//! is the hint. Files without `pattern` stay plain context rules.
6
7use std::path::{Path, PathBuf};
8
9use crate::types::{Config, StreamRuleConfig, StreamRuleRepeat, StreamRuleScope};
10
11impl Config {
12    /// Merge `.whycodes/rules/*.md` stream rules into `session.stream_rules`.
13    /// A file rule replaces a config-table rule of the same name.
14    pub fn load_stream_rule_files(&mut self, project_dir: &Path) {
15        for rule in stream_rule_files(project_dir) {
16            let rules = &mut self.session.stream_rules;
17            match rules.iter_mut().find(|r| r.name.trim() == rule.name) {
18                Some(slot) => *slot = rule,
19                None => rules.push(rule),
20            }
21        }
22    }
23}
24
25/// Stream rules defined by files in the first [`whycodes_core::project_rule_dirs`]
26/// entry (`.whycodes/rules/*.md`), sorted by path.
27pub fn stream_rule_files(project_dir: &Path) -> Vec<StreamRuleConfig> {
28    let [(dir, ext), ..] = whycodes_core::project_rule_dirs(project_dir);
29    let Ok(entries) = std::fs::read_dir(&dir) else {
30        return Vec::new();
31    };
32    let mut paths: Vec<PathBuf> = entries
33        .flatten()
34        .map(|e| e.path())
35        .filter(|p| {
36            p.is_file()
37                && p.extension()
38                    .and_then(|e| e.to_str())
39                    .is_some_and(|e| e.eq_ignore_ascii_case(ext))
40        })
41        .collect();
42    paths.sort();
43    paths.iter().filter_map(|p| read_rule_file(p)).collect()
44}
45
46fn read_rule_file(path: &Path) -> Option<StreamRuleConfig> {
47    let text = match std::fs::read_to_string(path) {
48        Ok(t) => t,
49        Err(e) => {
50            tracing::warn!(path = %path.display(), error = %e, "stream rule file unreadable");
51            return None;
52        }
53    };
54    let stem = path
55        .file_stem()
56        .map(|s| s.to_string_lossy().into_owned())
57        .unwrap_or_default();
58    parse_rule_markdown(&stem, &text)
59}
60
61/// Parse one rule file. `None` when it has no frontmatter `pattern`.
62pub fn parse_rule_markdown(stem: &str, content: &str) -> Option<StreamRuleConfig> {
63    let rest = content
64        .trim_start_matches('\u{feff}')
65        .trim_start()
66        .strip_prefix("---")?;
67    let (front, body) = rest.split_once("\n---")?;
68    let body = body.split_once('\n').map_or("", |(_, b)| b);
69    let mut rule = StreamRuleConfig {
70        name: stem.to_string(),
71        hint: body.trim().to_string(),
72        ..Default::default()
73    };
74    // Key of a block list (`scope:` then `- text` lines) being read.
75    let mut list_key = String::new();
76    for line in front.lines() {
77        let trimmed = line.trim();
78        if let Some(item) = trimmed.strip_prefix("- ") {
79            apply_key(&mut rule, &list_key, item, true);
80            continue;
81        }
82        let Some((k, v)) = trimmed.split_once(':') else {
83            continue;
84        };
85        list_key = k.trim().to_string();
86        apply_key(&mut rule, &list_key, v.trim(), false);
87    }
88    (!rule.pattern.is_empty()).then_some(rule)
89}
90
91fn apply_key(rule: &mut StreamRuleConfig, key: &str, value: &str, append: bool) {
92    if value.is_empty() {
93        return;
94    }
95    match key {
96        "pattern" => rule.pattern = unquote(value),
97        "name" => rule.name = unquote(value),
98        "scope" => {
99            if !append {
100                rule.scope.clear();
101            }
102            for item in list(value) {
103                match StreamRuleScope::parse(&item) {
104                    Some(s) => rule.scope.push(s),
105                    None => {
106                        tracing::warn!(rule = %rule.name, scope = %item, "unknown stream rule scope")
107                    }
108                }
109            }
110        }
111        "tools" => {
112            if !append {
113                rule.tools.clear();
114            }
115            rule.tools.extend(list(value));
116        }
117        "repeat" => match StreamRuleRepeat::parse(value) {
118            Some(r) => rule.repeat = r,
119            None => {
120                tracing::warn!(rule = %rule.name, repeat = %value, "unknown stream rule repeat")
121            }
122        },
123        _ => {}
124    }
125}
126
127/// `[a, "b"]` or a bare `a` → items.
128fn list(value: &str) -> Vec<String> {
129    let inner = value.trim().trim_start_matches('[').trim_end_matches(']');
130    inner
131        .split(',')
132        .map(unquote)
133        .filter(|s| !s.is_empty())
134        .collect()
135}
136
137/// YAML scalar: `'single'` (`''` = `'`), `"double"` (`\"` and `\`), or plain.
138fn unquote(value: &str) -> String {
139    let v = value.trim();
140    if let Some(inner) = v.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
141        return inner.replace("''", "'");
142    }
143    let Some(inner) = v.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else {
144        return v.to_string();
145    };
146    let mut out = String::with_capacity(inner.len());
147    let mut chars = inner.chars();
148    while let Some(c) = chars.next() {
149        if c != '\\' {
150            out.push(c);
151            continue;
152        }
153        match chars.next() {
154            Some(n @ ('\\' | '"')) => out.push(n),
155            Some(n) => {
156                out.push('\\');
157                out.push(n);
158            }
159            None => out.push('\\'),
160        }
161    }
162    out
163}
164
165#[cfg(test)]
166#[path = "stream_rules_tests.rs"]
167mod tests;