lean-ctx 3.8.4

Context Runtime for AI Agents with CCP. 71 MCP tools, 10 read modes, 95+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24+ AI tools. Reduces LLM token consumption by up to 99%.
Documentation
//! Read / render / merge generated skillify rule files (`.cursor/rules/<slug>.mdc`).
//!
//! Generated rules are namespaced with a `skillify-` prefix so they never collide
//! with hand-written rules. Each file embeds a machine-readable provenance comment
//! (`version`, `created`, …) that lets a re-run MERGE — bumping the version only
//! when the distilled body actually changes (idempotent otherwise).

use std::path::{Path, PathBuf};

use super::candidate::SkillCandidate;

/// Namespace prefix for generated rule slugs/files.
pub const SLUG_PREFIX: &str = "skillify-";
/// Leading token of the machine-readable provenance comment.
const PROV_PREFIX: &str = "<!-- lean-ctx-skillify:";

/// Result of writing a candidate to disk.
#[derive(Debug, Clone, PartialEq)]
pub enum WriteOutcome {
    /// Brand-new rule file written.
    Created,
    /// Existing rule whose body changed — version bumped.
    Merged,
    /// Existing rule with identical body — left untouched.
    Unchanged,
}

/// The fields parsed back out of an existing generated rule.
#[derive(Debug, Clone)]
pub struct ExistingRule {
    pub version: u32,
    pub created: String,
    pub body: String,
}

/// `<output_root>/.cursor/rules`.
pub fn rules_dir(output_root: &Path) -> PathBuf {
    output_root.join(".cursor").join("rules")
}

/// Full namespaced slug for a candidate slug (`stop-before-build` → `skillify-stop-before-build`).
pub fn full_slug(candidate_slug: &str) -> String {
    format!("{SLUG_PREFIX}{candidate_slug}")
}

/// File path for a *full* (already-namespaced) slug.
pub fn rule_path(output_root: &Path, full_slug: &str) -> PathBuf {
    rules_dir(output_root).join(format!("{full_slug}.mdc"))
}

/// Render a complete `.mdc` document for a candidate at `version`.
pub 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(),
    )
}

/// Make a title safe for a double-quoted YAML scalar on one line.
fn sanitize_description(s: &str) -> String {
    s.replace('\\', " ")
        .replace('"', "'")
        .replace(['\n', '\r'], " ")
        .trim()
        .to_string()
}

/// Parse the provenance + body out of an existing generated rule.
pub 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),
    })
}

/// Read the `description:` value from a generated rule's frontmatter.
pub 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; // end of frontmatter
        }
    }
    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())
}

/// The body is everything after the two leading comment lines (provenance +
/// auto-gen note) that follow the frontmatter. Falls back to the whole content
/// if the markers were removed, so a diverged file still compares (and re-bumps).
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(())
}

/// Write a candidate, creating a new file or merging into an existing one.
/// Idempotent: an unchanged body is left untouched.
pub 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
        );
        // Same body again → no-op.
        assert_eq!(
            write_candidate(&dir, &c1, "2026-01-02T00:00:00Z").unwrap(),
            WriteOutcome::Unchanged
        );
        // Changed body → merge + version bump.
        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'));
    }
}