Skip to main content

lean_ctx/core/skillify/
rule_file.rs

1//! Read / render / merge generated skillify rule files (`.cursor/rules/<slug>.mdc`).
2//!
3//! Generated rules are namespaced with a `skillify-` prefix so they never collide
4//! with hand-written rules. Each file embeds a machine-readable provenance comment
5//! (`version`, `created`, …) that lets a re-run MERGE — bumping the version only
6//! when the distilled body actually changes (idempotent otherwise).
7
8use std::path::{Path, PathBuf};
9
10use super::candidate::SkillCandidate;
11
12/// Namespace prefix for generated rule slugs/files.
13pub const SLUG_PREFIX: &str = "skillify-";
14/// Leading token of the machine-readable provenance comment.
15const PROV_PREFIX: &str = "<!-- lean-ctx-skillify:";
16
17/// Result of writing a candidate to disk.
18#[derive(Debug, Clone, PartialEq)]
19pub enum WriteOutcome {
20    /// Brand-new rule file written.
21    Created,
22    /// Existing rule whose body changed — version bumped.
23    Merged,
24    /// Existing rule with identical body — left untouched.
25    Unchanged,
26}
27
28/// The fields parsed back out of an existing generated rule.
29#[derive(Debug, Clone)]
30pub struct ExistingRule {
31    pub version: u32,
32    pub created: String,
33    pub body: String,
34}
35
36/// `<output_root>/.cursor/rules`.
37pub fn rules_dir(output_root: &Path) -> PathBuf {
38    output_root.join(".cursor").join("rules")
39}
40
41/// Full namespaced slug for a candidate slug (`stop-before-build` → `skillify-stop-before-build`).
42pub fn full_slug(candidate_slug: &str) -> String {
43    format!("{SLUG_PREFIX}{candidate_slug}")
44}
45
46/// File path for a *full* (already-namespaced) slug.
47pub fn rule_path(output_root: &Path, full_slug: &str) -> PathBuf {
48    rules_dir(output_root).join(format!("{full_slug}.mdc"))
49}
50
51/// Render a complete `.mdc` document for a candidate at `version`.
52pub fn render(candidate: &SkillCandidate, version: u32, created: &str, updated: &str) -> String {
53    let sources = candidate.sources.join(",");
54    format!(
55        "---\n\
56         description: \"{desc}\"\n\
57         globs: \"**/*\"\n\
58         alwaysApply: false\n\
59         ---\n\n\
60         {PROV_PREFIX} version={version} created={created} updated={updated} \
61         category={cat} recurrence={rec} confidence={conf:.2} sources={sources} -->\n\
62         <!-- Auto-generated by `lean-ctx skillify` from this project's session diary + \
63         knowledge. Edit freely; re-running skillify MERGEs (bumps version) only when the \
64         distilled content changes. -->\n\n\
65         {body}\n",
66        desc = sanitize_description(&candidate.title),
67        cat = candidate.category,
68        rec = candidate.recurrence,
69        conf = candidate.confidence,
70        body = candidate.body.trim(),
71    )
72}
73
74/// Make a title safe for a double-quoted YAML scalar on one line.
75fn sanitize_description(s: &str) -> String {
76    s.replace('\\', " ")
77        .replace('"', "'")
78        .replace(['\n', '\r'], " ")
79        .trim()
80        .to_string()
81}
82
83/// Parse the provenance + body out of an existing generated rule.
84pub fn parse_existing(content: &str) -> Option<ExistingRule> {
85    let version = extract_prov_field(content, "version=")?.parse().ok()?;
86    let created = extract_prov_field(content, "created=").unwrap_or_default();
87    Some(ExistingRule {
88        version,
89        created,
90        body: body_after_provenance(content),
91    })
92}
93
94/// Read the `description:` value from a generated rule's frontmatter.
95pub fn extract_description(content: &str) -> Option<String> {
96    for line in content.lines() {
97        let t = line.trim();
98        if let Some(rest) = t.strip_prefix("description:") {
99            return Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
100        }
101        if t == "---" && !content.starts_with(line) {
102            break; // end of frontmatter
103        }
104    }
105    None
106}
107
108fn extract_prov_field(content: &str, key: &str) -> Option<String> {
109    let line = content.lines().find(|l| l.contains(PROV_PREFIX))?;
110    let start = line.find(key)? + key.len();
111    let rest = &line[start..];
112    let end = rest.find(' ').unwrap_or(rest.len());
113    Some(rest[..end].to_string())
114}
115
116/// The body is everything after the two leading comment lines (provenance +
117/// auto-gen note) that follow the frontmatter. Falls back to the whole content
118/// if the markers were removed, so a diverged file still compares (and re-bumps).
119fn body_after_provenance(content: &str) -> String {
120    let mut found = 0;
121    for (i, _) in content.match_indices("-->") {
122        found += 1;
123        if found == 2 {
124            return content[i + 3..].trim().to_string();
125        }
126    }
127    content.trim().to_string()
128}
129
130fn ensure_parent(path: &Path) -> Result<(), String> {
131    if let Some(parent) = path.parent() {
132        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
133    }
134    Ok(())
135}
136
137/// Write a candidate, creating a new file or merging into an existing one.
138/// Idempotent: an unchanged body is left untouched.
139pub fn write_candidate(
140    output_root: &Path,
141    candidate: &SkillCandidate,
142    now: &str,
143) -> Result<WriteOutcome, String> {
144    let slug = full_slug(&candidate.slug);
145    let path = rule_path(output_root, &slug);
146    let existing = std::fs::read_to_string(&path)
147        .ok()
148        .and_then(|c| parse_existing(&c));
149
150    if let Some(prev) = existing {
151        if prev.body == candidate.body.trim() {
152            return Ok(WriteOutcome::Unchanged);
153        }
154        let created = if prev.created.is_empty() {
155            now.to_string()
156        } else {
157            prev.created
158        };
159        let content = render(candidate, prev.version + 1, &created, now);
160        ensure_parent(&path)?;
161        crate::config_io::write_atomic_with_backup(&path, &content)?;
162        Ok(WriteOutcome::Merged)
163    } else {
164        let content = render(candidate, 1, now, now);
165        ensure_parent(&path)?;
166        crate::config_io::write_atomic_with_backup(&path, &content)?;
167        Ok(WriteOutcome::Created)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn cand(body: &str) -> SkillCandidate {
176        SkillCandidate {
177            slug: "stop-before-build".into(),
178            title: "Stop before build".into(),
179            body: body.into(),
180            category: "decision".into(),
181            recurrence: 3,
182            confidence: 0.8,
183            sources: vec!["sess1".into()],
184        }
185    }
186
187    #[test]
188    fn render_roundtrips_through_parse() {
189        let doc = render(&cand("Run lean-ctx stop before building."), 2, "C", "U");
190        let parsed = parse_existing(&doc).unwrap();
191        assert_eq!(parsed.version, 2);
192        assert_eq!(parsed.created, "C");
193        assert_eq!(parsed.body, "Run lean-ctx stop before building.");
194        assert_eq!(
195            extract_description(&doc).as_deref(),
196            Some("Stop before build")
197        );
198    }
199
200    #[test]
201    fn create_then_unchanged_then_merge() {
202        let dir = std::env::temp_dir().join(format!("skillify-rf-{}", std::process::id()));
203        let _ = std::fs::remove_dir_all(&dir);
204        let c1 = cand("Run lean-ctx stop before building.");
205
206        assert_eq!(
207            write_candidate(&dir, &c1, "2026-01-01T00:00:00Z").unwrap(),
208            WriteOutcome::Created
209        );
210        // Same body again → no-op.
211        assert_eq!(
212            write_candidate(&dir, &c1, "2026-01-02T00:00:00Z").unwrap(),
213            WriteOutcome::Unchanged
214        );
215        // Changed body → merge + version bump.
216        let c2 = cand("Run lean-ctx stop before building; the LaunchAgent respawns otherwise.");
217        assert_eq!(
218            write_candidate(&dir, &c2, "2026-01-03T00:00:00Z").unwrap(),
219            WriteOutcome::Merged
220        );
221
222        let path = rule_path(&dir, &full_slug("stop-before-build"));
223        let parsed = parse_existing(&std::fs::read_to_string(&path).unwrap()).unwrap();
224        assert_eq!(parsed.version, 2, "version bumped on change");
225        assert_eq!(parsed.created, "2026-01-01T00:00:00Z", "created preserved");
226        let _ = std::fs::remove_dir_all(&dir);
227    }
228
229    #[test]
230    fn sanitize_description_is_single_line_quote_safe() {
231        let s = sanitize_description("a \"quoted\"\nmulti-line");
232        assert!(!s.contains('"'));
233        assert!(!s.contains('\n'));
234    }
235}