lean_ctx_server/tools/
ctx_skillify.rs1use std::collections::BTreeMap;
6
7use lean_ctx::core::skillify::{self, RuleNotice};
8
9pub fn handle(project_root: &str, action: &str, slug: Option<&str>) -> String {
12 match action.trim() {
13 "" | "mine" => render_mine(project_root),
14 "list" => render_list(project_root),
15 "status" => render_status(project_root),
16 "promote" => render_promote(project_root, slug),
17 other => format!(
18 "ERR: unknown skillify action '{other}'. Use: mine | list | status | promote <slug>"
19 ),
20 }
21}
22
23fn push_notices(out: &mut String, notices: &[RuleNotice]) {
27 if notices.is_empty() {
28 return;
29 }
30 out.push_str(&format!("Needs your attention ({}):\n", notices.len()));
31 for notice in notices {
32 out.push_str(&format!(
33 " {} — {} [{}]\n",
34 notice.path, notice.reason, notice.code
35 ));
36 }
37}
38
39fn skip_reasons(skipped: &[(String, String)]) -> BTreeMap<&str, usize> {
46 let mut reasons = BTreeMap::new();
47 for (_, reason) in skipped {
48 let code = if reason.starts_with("one-off") {
49 "one-off"
50 } else {
51 reason.as_str()
52 };
53 *reasons.entry(code).or_insert(0) += 1;
54 }
55 reasons
56}
57
58fn render_mine(project_root: &str) -> String {
59 match skillify::mine(project_root) {
60 Err(e) => format!("skillify: {e}"),
61 Ok(r) => {
62 let mut out = format!(
63 "skillify mine → {} created, {} merged, {} unchanged, {} skipped ({} candidates)\n",
64 r.created.len(),
65 r.merged.len(),
66 r.unchanged.len(),
67 r.skipped.len(),
68 r.candidates_seen,
69 );
70 out.push_str(&format!("output: {}\n", r.output_dir));
71 for name in &r.created {
72 out.push_str(&format!(" + {name}\n"));
73 }
74 for name in &r.merged {
75 out.push_str(&format!(" ~ {name} (version bumped)\n"));
76 }
77 if r.created.is_empty() && r.merged.is_empty() {
78 out.push_str(" (no new or changed rules)\n");
79 }
80 for (reason, count) in skip_reasons(&r.skipped) {
81 out.push_str(&format!(" - skipped {count}: {reason}\n"));
82 }
83 push_notices(&mut out, &r.notices);
84 out
85 }
86 }
87}
88
89fn render_list(project_root: &str) -> String {
90 let listing = skillify::list_rules(project_root);
91 if listing.rules.is_empty() && listing.notices.is_empty() {
92 return "skillify: no generated rules yet — run `skillify mine`".to_string();
93 }
94 let mut out = format!("skillify rules ({}):\n", listing.rules.len());
95 for r in &listing.rules {
96 out.push_str(&format!(" {} v{} — {}\n", r.slug, r.version, r.title));
97 }
98 push_notices(&mut out, &listing.notices);
99 out
100}
101
102fn render_status(project_root: &str) -> String {
103 let cfg = skillify::current_config();
104 let candidates = skillify::candidate::mine_candidates(project_root).len();
105 let listing = skillify::list_rules(project_root);
106 format!(
107 "skillify status\n enabled: {}\n scope: {}\n min_confidence: {:.2}\n min_recurrence: {}\n candidates available: {}\n generated rules: {}\n needs your attention: {}",
108 cfg.enabled,
109 cfg.scope,
110 cfg.min_confidence,
111 cfg.min_recurrence,
112 candidates,
113 listing.rules.len(),
114 listing.notices.len()
115 )
116}
117
118fn render_promote(project_root: &str, slug: Option<&str>) -> String {
119 let Some(slug) = slug.filter(|s| !s.trim().is_empty()) else {
120 return "ERR: promote requires a rule slug (see `skillify list`)".to_string();
121 };
122 match skillify::promote(project_root, slug.trim()) {
123 Ok(dst) => format!("skillify: promoted `{slug}` → {dst}"),
124 Err(e) => format!("skillify: {e}"),
125 }
126}