lean_ctx/core/skillify/
mod.rs1pub mod candidate;
10pub mod gate;
11pub mod rule_file;
12
13use std::path::PathBuf;
14
15use gate::Verdict;
16use rule_file::WriteOutcome;
17
18#[derive(Debug, Default)]
20pub struct MineReport {
21 pub created: Vec<String>,
22 pub merged: Vec<String>,
23 pub unchanged: Vec<String>,
24 pub skipped: Vec<(String, String)>,
26 pub candidates_seen: usize,
27 pub output_dir: String,
28}
29
30#[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
42fn 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
52pub 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
85pub 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
120pub 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
142pub fn current_config() -> crate::core::config::SkillifyConfig {
144 config()
145}