Skip to main content

lean_ctx/core/skillify/
mod.rs

1//! Skillify (#290): distill a project's recurring session patterns into versioned,
2//! git-committable `.cursor/rules/skillify-*.mdc` rule files.
3//!
4//! Pipeline: [`candidate::mine_candidates`] (read diary + knowledge) →
5//! [`gate::judge`] (precision-biased KEEP/SKIP) → [`rule_file::write_candidate`]
6//! (create / merge / unchanged). Runs on demand only; nothing is written unless
7//! the miner is invoked, and re-runs are idempotent.
8
9pub mod candidate;
10pub mod gate;
11pub mod rule_file;
12
13use std::path::PathBuf;
14
15use gate::Verdict;
16use rule_file::WriteOutcome;
17
18/// Summary of one mining run, for human + machine reporting.
19#[derive(Debug, Default)]
20pub struct MineReport {
21    pub created: Vec<String>,
22    pub merged: Vec<String>,
23    pub unchanged: Vec<String>,
24    /// `(title, reason)` for every rejected candidate.
25    pub skipped: Vec<(String, String)>,
26    pub candidates_seen: usize,
27    pub output_dir: String,
28}
29
30/// One generated rule on disk.
31#[derive(Debug, Clone)]
32pub struct RuleSummary {
33    pub slug: String,
34    pub version: u32,
35    pub title: String,
36}
37
38fn config() -> crate::core::config::SkillifyConfig {
39    crate::core::config::Config::load().skillify
40}
41
42/// Resolve the output root from the configured scope.
43fn output_root(project_root: &str, scope: &str) -> PathBuf {
44    if scope.eq_ignore_ascii_case("global")
45        && let Some(home) = dirs::home_dir()
46    {
47        return home;
48    }
49    PathBuf::from(project_root)
50}
51
52/// Run the miner end-to-end. Returns an error only when skillify is disabled or
53/// a write fails; an empty project simply yields an empty report.
54pub fn mine(project_root: &str) -> Result<MineReport, String> {
55    let cfg = config();
56    if !cfg.enabled {
57        return Err("skillify is disabled — set `[skillify] enabled = true`".to_string());
58    }
59    let root = output_root(project_root, &cfg.scope);
60    let now = chrono::Utc::now().to_rfc3339();
61    let candidates = candidate::mine_candidates(project_root);
62
63    let mut report = MineReport {
64        candidates_seen: candidates.len(),
65        output_dir: rule_file::rules_dir(&root).display().to_string(),
66        ..Default::default()
67    };
68
69    for c in &candidates {
70        match gate::judge(c, cfg.min_confidence, cfg.min_recurrence) {
71            Verdict::Skip(reason) => report.skipped.push((c.title.clone(), reason)),
72            Verdict::Keep => {
73                let full = rule_file::full_slug(&c.slug);
74                match rule_file::write_candidate(&root, c, &now)? {
75                    WriteOutcome::Created => report.created.push(full),
76                    WriteOutcome::Merged => report.merged.push(full),
77                    WriteOutcome::Unchanged => report.unchanged.push(full),
78                }
79            }
80        }
81    }
82    Ok(report)
83}
84
85/// List the generated rules currently on disk for the configured scope.
86pub fn list_rules(project_root: &str) -> Vec<RuleSummary> {
87    let cfg = config();
88    let root = output_root(project_root, &cfg.scope);
89    let dir = rule_file::rules_dir(&root);
90    let mut out = Vec::new();
91    let Ok(entries) = std::fs::read_dir(&dir) else {
92        return out;
93    };
94    for entry in entries.flatten() {
95        let path = entry.path();
96        if path.extension().and_then(|e| e.to_str()) != Some("mdc") {
97            continue;
98        }
99        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
100            continue;
101        };
102        if !stem.starts_with(rule_file::SLUG_PREFIX) {
103            continue;
104        }
105        if let Ok(content) = std::fs::read_to_string(&path) {
106            let version = rule_file::parse_existing(&content).map_or(1, |r| r.version);
107            let title =
108                rule_file::extract_description(&content).unwrap_or_else(|| stem.to_string());
109            out.push(RuleSummary {
110                slug: stem.to_string(),
111                version,
112                title,
113            });
114        }
115    }
116    out.sort_by(|a, b| a.slug.cmp(&b.slug));
117    out
118}
119
120/// Copy a project-scoped generated rule into the global `~/.cursor/rules` so it
121/// applies to every project. Returns the destination path on success.
122pub fn promote(project_root: &str, slug: &str) -> Result<String, String> {
123    let full = if slug.starts_with(rule_file::SLUG_PREFIX) {
124        slug.to_string()
125    } else {
126        rule_file::full_slug(slug)
127    };
128    let src = rule_file::rule_path(&PathBuf::from(project_root), &full);
129    if !src.exists() {
130        return Err(format!("no generated rule `{full}` in this project"));
131    }
132    let home = dirs::home_dir().ok_or("cannot resolve home directory")?;
133    let dst = rule_file::rule_path(&home, &full);
134    if let Some(parent) = dst.parent() {
135        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
136    }
137    let content = std::fs::read_to_string(&src).map_err(|e| e.to_string())?;
138    crate::config_io::write_atomic_with_backup(&dst, &content)?;
139    Ok(dst.display().to_string())
140}
141
142/// Current skillify configuration, for `status`.
143pub fn current_config() -> crate::core::config::SkillifyConfig {
144    config()
145}