Skip to main content

lean_ctx_server/tools/
ctx_skillify.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `ctx_skillify` business logic — codify recurring session patterns into
3//! versioned `.cursor/rules/skillify-*.mdc` files (#290).
4
5use std::collections::BTreeMap;
6
7use lean_ctx::core::skillify::{self, RuleNotice};
8
9/// Dispatch a skillify action and return human-readable text (shared by the MCP
10/// tool and the CLI).
11pub 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
23/// The block for rule files lean-ctx reports instead of listing: path, reason
24/// code and a fixed sentence — nothing of a file's content. The notices come
25/// sorted by path, so identical calls print identical text (#498).
26fn 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
39/// Why candidates were skipped, as `reason code → count`. The first element
40/// of a `skipped` entry is a candidate's TITLE — a fact key or the first
41/// sentence of its body — so it is never printed. The reasons are fixed
42/// strings (`gate::judge`: "too short", "generic / one-off phrasing") or
43/// typed gateway refusals (reason codes only); the one that carries numbers
44/// of a candidate ("one-off (recurrence …)") is reduced to its code.
45fn 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}