lean_ctx/core/skillify/
rule_file.rs1use std::path::{Path, PathBuf};
9
10use super::candidate::SkillCandidate;
11
12pub const SLUG_PREFIX: &str = "skillify-";
14const PROV_PREFIX: &str = "<!-- lean-ctx-skillify:";
16
17#[derive(Debug, Clone, PartialEq)]
19pub enum WriteOutcome {
20 Created,
22 Merged,
24 Unchanged,
26}
27
28#[derive(Debug, Clone)]
30pub struct ExistingRule {
31 pub version: u32,
32 pub created: String,
33 pub body: String,
34}
35
36pub fn rules_dir(output_root: &Path) -> PathBuf {
38 output_root.join(".cursor").join("rules")
39}
40
41pub fn full_slug(candidate_slug: &str) -> String {
43 format!("{SLUG_PREFIX}{candidate_slug}")
44}
45
46pub fn rule_path(output_root: &Path, full_slug: &str) -> PathBuf {
48 rules_dir(output_root).join(format!("{full_slug}.mdc"))
49}
50
51pub 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
74fn sanitize_description(s: &str) -> String {
76 s.replace('\\', " ")
77 .replace('"', "'")
78 .replace(['\n', '\r'], " ")
79 .trim()
80 .to_string()
81}
82
83pub 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
94pub 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; }
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
116fn 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
137pub 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 assert_eq!(
212 write_candidate(&dir, &c1, "2026-01-02T00:00:00Z").unwrap(),
213 WriteOutcome::Unchanged
214 );
215 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}