lean-ctx-engine 3.12.1

LeanCTX | OSS library: the open-source runtime for the Context Gateway for AI agents behind the lean-ctx CLI, MCP server and proxy integrations.
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(crate) 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(crate) 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(crate) struct ExistingRule {
    pub version: u32,
    pub created: String,
    pub body: String,
}

/// `<output_root>/.cursor/rules`.
pub(crate) 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(crate) fn full_slug(candidate_slug: &str) -> String {
    format!("{SLUG_PREFIX}{candidate_slug}")
}

/// File path for a *full* (already-namespaced) slug.
pub(crate) 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(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(),
    )
}

/// 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(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),
    })
}

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

/// What writing a candidate will do, decided before anything is written: the
/// rule's source binding is made of exactly [`Planned::text`], and a rule
/// that cannot be bound under protection is never written (`provenance`).
#[derive(Debug)]
pub(crate) struct Planned {
    pub path: PathBuf,
    pub outcome: WriteOutcome,
    /// The document the file holds once [`Planned::write`] returned: the
    /// rendered rule, or the existing file as it is for an unchanged body.
    pub text: String,
}

impl Planned {
    /// Create or replace the file. Idempotent: an unchanged body is left
    /// untouched.
    pub(crate) fn write(&self) -> Result<(), String> {
        if self.outcome == WriteOutcome::Unchanged {
            return Ok(());
        }
        ensure_parent(&self.path)?;
        crate::config_io::write_atomic_with_backup(&self.path, &self.text)
    }
}

/// Plan the write of a candidate: a new file, or a merge into an existing one.
pub(crate) fn plan(output_root: &Path, candidate: &SkillCandidate, now: &str) -> Planned {
    let slug = full_slug(&candidate.slug);
    let path = rule_path(output_root, &slug);
    let on_disk = std::fs::read_to_string(&path).ok();
    let existing = on_disk
        .as_deref()
        .and_then(|content| parse_existing(content).map(|rule| (rule, content)));

    let (outcome, text) = match existing {
        Some((prev, content)) if prev.body == candidate.body.trim() => {
            (WriteOutcome::Unchanged, content.to_owned())
        }
        Some((prev, _)) => {
            let created = if prev.created.is_empty() {
                now
            } else {
                prev.created.as_str()
            };
            let content = render(candidate, prev.version + 1, created, now);
            (WriteOutcome::Merged, content)
        }
        None => (WriteOutcome::Created, render(candidate, 1, now, now)),
    };
    Planned {
        path,
        outcome,
        text,
    }
}

/// Write a candidate, creating a new file or merging into an existing one.
#[cfg(test)]
pub(crate) fn write_candidate(
    output_root: &Path,
    candidate: &SkillCandidate,
    now: &str,
) -> Result<WriteOutcome, String> {
    let planned = plan(output_root, candidate, now);
    planned.write()?;
    Ok(planned.outcome)
}

#[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()],
            derivation: crate::core::skillify::candidate::Derivation::default(),
        }
    }

    #[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'));
    }
}