difflore-cli 0.7.0

Your AI coding agent learned public code, not your team's private decisions. difflore turns past PR reviews into source-backed local rules.
//! Cursor rules emitter: one generated `.mdc` file per rule under
//! `.cursor/rules/`. Cursor's legacy `.cursorrules` file is intentionally not
//! written because Agent mode ignores it.

use std::collections::BTreeMap;

use difflore_core::export::{ExportRule, export_content_hash, render_export_body};

pub(crate) static CURSOR_RULES: super::Emitter = super::Emitter {
    format: "cursor-md",
    file_name: ".cursor/rules",
    engine: None,
    kind: super::EmitterKind::CursorRulesDir,
};

pub(crate) const CURSOR_RULE_FILE_PREFIX: &str = "difflore-";
pub(crate) const CURSOR_RULE_FILE_SUFFIX: &str = ".mdc";

pub(crate) fn is_difflore_cursor_rule_file_name(name: &str) -> bool {
    name.starts_with(CURSOR_RULE_FILE_PREFIX) && name.ends_with(CURSOR_RULE_FILE_SUFFIX)
}

pub(crate) fn render_cursor_rule_files(rules: &[ExportRule]) -> BTreeMap<String, String> {
    rules
        .iter()
        .map(|rule| (cursor_rule_file_name(rule), render_cursor_rule_file(rule)))
        .collect()
}

pub(crate) fn render_cursor_rules_manifest(rules: &[ExportRule]) -> String {
    let files = render_cursor_rule_files(rules);
    files
        .iter()
        .map(|(name, body)| format!("{name}\n{body}"))
        .collect::<Vec<_>>()
        .join("\n--- difflore-cursor-rule ---\n")
}

fn cursor_rule_file_name(rule: &ExportRule) -> String {
    let slug = slugify_rule_name(&rule.name);
    let hash = export_content_hash(&rule.id);
    let suffix = hash.get(..8).unwrap_or(hash.as_str());
    format!("{CURSOR_RULE_FILE_PREFIX}{slug}-{suffix}{CURSOR_RULE_FILE_SUFFIX}")
}

fn render_cursor_rule_file(rule: &ExportRule) -> String {
    let globs = rule.file_patterns.join(",");
    let body = render_export_body(std::slice::from_ref(rule));
    format!(
        "---\n\
         description: {description}\n\
         globs: {globs}\n\
         alwaysApply: false\n\
         ---\n\
         \n\
         <!-- generated by difflore export; do not edit directly. -->\n\
         \n\
         {body}\n",
        description = yaml_string(&rule.name),
        globs = yaml_string(&globs),
        body = body.trim_end(),
    )
}

fn yaml_string(value: &str) -> String {
    serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_owned())
}

fn slugify_rule_name(name: &str) -> String {
    let mut slug = String::new();
    let mut previous_dash = false;
    for ch in name.chars() {
        let ascii = ch.to_ascii_lowercase();
        if ascii.is_ascii_alphanumeric() {
            slug.push(ascii);
            previous_dash = false;
        } else if !previous_dash && !slug.is_empty() {
            slug.push('-');
            previous_dash = true;
        }
        if slug.len() >= 72 {
            break;
        }
    }
    let trimmed = slug.trim_matches('-');
    if trimmed.is_empty() {
        "rule".to_owned()
    } else {
        trimmed.to_owned()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn rule(id: &str, name: &str, patterns: &[&str]) -> ExportRule {
        ExportRule {
            id: id.to_owned(),
            name: name.to_owned(),
            description: "Prefer stable test waits.\nSource: acme/widgets#42".to_owned(),
            r#type: "review_standard".to_owned(),
            confidence: 0.8,
            origin: "pr_review".to_owned(),
            source_kind: "human".to_owned(),
            source: "local".to_owned(),
            repo_scope: Some("acme/widgets".to_owned()),
            check_prompt: Some("Did you avoid sleeps?".to_owned()),
            file_patterns: patterns
                .iter()
                .map(|pattern| (*pattern).to_owned())
                .collect(),
            examples: Vec::new(),
        }
    }

    #[test]
    fn cursor_rule_files_render_mdc_frontmatter_and_body() {
        let files = render_cursor_rule_files(&[rule(
            "rule-1",
            "Avoid sleeps in tests",
            &["tests/**/*.ts", "src/**/*.ts"],
        )]);
        let (name, body) = files.iter().next().expect("one file");

        assert!(name.starts_with("difflore-avoid-sleeps-in-tests-"));
        assert!(name.ends_with(".mdc"));
        assert!(body.starts_with("---\n"));
        assert!(body.contains("description: \"Avoid sleeps in tests\"\n"));
        assert!(body.contains("globs: \"tests/**/*.ts,src/**/*.ts\"\n"));
        assert!(body.contains("alwaysApply: false\n"));
        assert!(body.contains("generated by difflore export"));
        assert!(body.contains(difflore_core::export::REVIEW_SEARCH_RULES_INSTRUCTION));
        assert!(body.contains("## Avoid sleeps in tests"));
        assert!(body.contains("Prefer stable test waits."));
        assert!(body.contains("Source: acme/widgets#42"));
    }

    #[test]
    fn cursor_rule_file_names_are_deduped_by_rule_id_hash() {
        let files = render_cursor_rule_files(&[
            rule("rule-a", "Same Name", &[]),
            rule("rule-b", "Same Name", &[]),
        ]);
        let names: Vec<&String> = files.keys().collect();

        assert_eq!(names.len(), 2);
        assert_ne!(names[0], names[1]);
        assert!(
            names
                .iter()
                .all(|name| is_difflore_cursor_rule_file_name(name))
        );
    }
}