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))
);
}
}