use std::path::{Path, PathBuf};
use super::candidate::SkillCandidate;
pub(crate) const SLUG_PREFIX: &str = "skillify-";
const PROV_PREFIX: &str = "<!-- lean-ctx-skillify:";
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum WriteOutcome {
Created,
Merged,
Unchanged,
}
#[derive(Debug, Clone)]
pub(crate) struct ExistingRule {
pub version: u32,
pub created: String,
pub body: String,
}
pub(crate) fn rules_dir(output_root: &Path) -> PathBuf {
output_root.join(".cursor").join("rules")
}
pub(crate) fn full_slug(candidate_slug: &str) -> String {
format!("{SLUG_PREFIX}{candidate_slug}")
}
pub(crate) fn rule_path(output_root: &Path, full_slug: &str) -> PathBuf {
rules_dir(output_root).join(format!("{full_slug}.mdc"))
}
pub(crate) fn render(
candidate: &SkillCandidate,
version: u32,
created: &str,
updated: &str,
) -> String {
let sources = candidate.sources.join(",");
format!(
"---\n\
description: \"{desc}\"\n\
globs: \"**/*\"\n\
alwaysApply: false\n\
---\n\n\
{PROV_PREFIX} version={version} created={created} updated={updated} \
category={cat} recurrence={rec} confidence={conf:.2} sources={sources} -->\n\
<!-- Auto-generated by `lean-ctx skillify` from this project's session diary + \
knowledge. Edit freely; re-running skillify MERGEs (bumps version) only when the \
distilled content changes. -->\n\n\
{body}\n",
desc = sanitize_description(&candidate.title),
cat = candidate.category,
rec = candidate.recurrence,
conf = candidate.confidence,
body = candidate.body.trim(),
)
}
fn sanitize_description(s: &str) -> String {
s.replace('\\', " ")
.replace('"', "'")
.replace(['\n', '\r'], " ")
.trim()
.to_string()
}
pub(crate) fn parse_existing(content: &str) -> Option<ExistingRule> {
let version = extract_prov_field(content, "version=")?.parse().ok()?;
let created = extract_prov_field(content, "created=").unwrap_or_default();
Some(ExistingRule {
version,
created,
body: body_after_provenance(content),
})
}
pub(crate) fn extract_description(content: &str) -> Option<String> {
for line in content.lines() {
let t = line.trim();
if let Some(rest) = t.strip_prefix("description:") {
return Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
}
if t == "---" && !content.starts_with(line) {
break; }
}
None
}
fn extract_prov_field(content: &str, key: &str) -> Option<String> {
let line = content.lines().find(|l| l.contains(PROV_PREFIX))?;
let start = line.find(key)? + key.len();
let rest = &line[start..];
let end = rest.find(' ').unwrap_or(rest.len());
Some(rest[..end].to_string())
}
fn body_after_provenance(content: &str) -> String {
let mut found = 0;
for (i, _) in content.match_indices("-->") {
found += 1;
if found == 2 {
return content[i + 3..].trim().to_string();
}
}
content.trim().to_string()
}
fn ensure_parent(path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
Ok(())
}
pub(crate) fn write_candidate(
output_root: &Path,
candidate: &SkillCandidate,
now: &str,
) -> Result<WriteOutcome, String> {
let slug = full_slug(&candidate.slug);
let path = rule_path(output_root, &slug);
let existing = std::fs::read_to_string(&path)
.ok()
.and_then(|c| parse_existing(&c));
if let Some(prev) = existing {
if prev.body == candidate.body.trim() {
return Ok(WriteOutcome::Unchanged);
}
let created = if prev.created.is_empty() {
now.to_string()
} else {
prev.created
};
let content = render(candidate, prev.version + 1, &created, now);
ensure_parent(&path)?;
crate::config_io::write_atomic_with_backup(&path, &content)?;
Ok(WriteOutcome::Merged)
} else {
let content = render(candidate, 1, now, now);
ensure_parent(&path)?;
crate::config_io::write_atomic_with_backup(&path, &content)?;
Ok(WriteOutcome::Created)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cand(body: &str) -> SkillCandidate {
SkillCandidate {
slug: "stop-before-build".into(),
title: "Stop before build".into(),
body: body.into(),
category: "decision".into(),
recurrence: 3,
confidence: 0.8,
sources: vec!["sess1".into()],
}
}
#[test]
fn render_roundtrips_through_parse() {
let doc = render(&cand("Run lean-ctx stop before building."), 2, "C", "U");
let parsed = parse_existing(&doc).unwrap();
assert_eq!(parsed.version, 2);
assert_eq!(parsed.created, "C");
assert_eq!(parsed.body, "Run lean-ctx stop before building.");
assert_eq!(
extract_description(&doc).as_deref(),
Some("Stop before build")
);
}
#[test]
fn create_then_unchanged_then_merge() {
let dir = std::env::temp_dir().join(format!("skillify-rf-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let c1 = cand("Run lean-ctx stop before building.");
assert_eq!(
write_candidate(&dir, &c1, "2026-01-01T00:00:00Z").unwrap(),
WriteOutcome::Created
);
assert_eq!(
write_candidate(&dir, &c1, "2026-01-02T00:00:00Z").unwrap(),
WriteOutcome::Unchanged
);
let c2 = cand("Run lean-ctx stop before building; the LaunchAgent respawns otherwise.");
assert_eq!(
write_candidate(&dir, &c2, "2026-01-03T00:00:00Z").unwrap(),
WriteOutcome::Merged
);
let path = rule_path(&dir, &full_slug("stop-before-build"));
let parsed = parse_existing(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(parsed.version, 2, "version bumped on change");
assert_eq!(parsed.created, "2026-01-01T00:00:00Z", "created preserved");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sanitize_description_is_single_line_quote_safe() {
let s = sanitize_description("a \"quoted\"\nmulti-line");
assert!(!s.contains('"'));
assert!(!s.contains('\n'));
}
}