Skip to main content

lean_ctx/
status.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct StatusReport {
6    pub schema_version: u32,
7    pub generated_at: DateTime<Utc>,
8    pub version: String,
9    pub setup_report: Option<crate::core::setup_report::SetupReport>,
10    pub doctor_compact_passed: u32,
11    pub doctor_compact_total: u32,
12    pub mcp_targets: Vec<McpTargetStatus>,
13    pub rules_targets: Vec<crate::rules_inject::RulesTargetStatus>,
14    pub warnings: Vec<String>,
15    pub errors: Vec<String>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct McpTargetStatus {
20    pub name: String,
21    pub detected: bool,
22    pub config_path: String,
23    pub state: String,
24    pub note: Option<String>,
25}
26
27pub fn run_cli(args: &[String]) -> i32 {
28    let json = args.iter().any(|a| a == "--json");
29    let help = args.iter().any(|a| a == "--help" || a == "-h");
30    if help {
31        println!("Usage:");
32        println!("  lean-ctx status [--json]");
33        return 0;
34    }
35
36    match build_status_report() {
37        Ok((report, path)) => {
38            let text = serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string());
39            let _ = crate::config_io::write_atomic_with_backup(&path, &text);
40
41            if json {
42                println!("{text}");
43            } else {
44                print_human(&report, &path);
45            }
46
47            i32::from(!report.errors.is_empty())
48        }
49        Err(e) => {
50            eprintln!("{e}");
51            2
52        }
53    }
54}
55
56fn build_status_report() -> Result<(StatusReport, std::path::PathBuf), String> {
57    let generated_at = Utc::now();
58    let version = env!("CARGO_PKG_VERSION").to_string();
59    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
60
61    let mut warnings: Vec<String> = Vec::new();
62    let errors: Vec<String> = Vec::new();
63
64    let setup_report = {
65        let path = crate::core::setup_report::SetupReport::default_path()?;
66        if path.exists() {
67            match std::fs::read_to_string(&path) {
68                Ok(s) => match serde_json::from_str::<crate::core::setup_report::SetupReport>(&s) {
69                    Ok(r) => Some(r),
70                    Err(e) => {
71                        warnings.push(format!("setup report parse error: {e}"));
72                        None
73                    }
74                },
75                Err(e) => {
76                    warnings.push(format!("setup report read error: {e}"));
77                    None
78                }
79            }
80        } else {
81            None
82        }
83    };
84
85    let (doctor_compact_passed, doctor_compact_total) = crate::doctor::compact_score();
86
87    // MCP targets (registry based)
88    let targets = crate::core::editor_registry::build_targets(&home);
89    let mut mcp_targets: Vec<McpTargetStatus> = Vec::new();
90    for t in &targets {
91        let detected = t.detect_path.exists();
92        let config_path = t.config_path.to_string_lossy().to_string();
93
94        let state = if !detected {
95            "not_detected".to_string()
96        } else if !t.config_path.exists() {
97            "missing_file".to_string()
98        } else {
99            match std::fs::read_to_string(&t.config_path) {
100                Ok(s) => {
101                    if s.contains("lean-ctx") {
102                        "configured".to_string()
103                    } else {
104                        "missing_entry".to_string()
105                    }
106                }
107                Err(e) => {
108                    warnings.push(format!("mcp config read error for {}: {e}", t.name));
109                    "read_error".to_string()
110                }
111            }
112        };
113
114        if detected {
115            mcp_targets.push(McpTargetStatus {
116                name: t.name.to_string(),
117                detected,
118                config_path,
119                state,
120                note: None,
121            });
122        }
123    }
124
125    if mcp_targets.is_empty() {
126        warnings.push("no supported AI tools detected".to_string());
127    }
128
129    let rules_targets = crate::rules_inject::collect_rules_status(&home);
130
131    let path = crate::core::setup_report::status_report_path()?;
132
133    let report = StatusReport {
134        schema_version: 1,
135        generated_at,
136        version,
137        setup_report,
138        doctor_compact_passed,
139        doctor_compact_total,
140        mcp_targets,
141        rules_targets,
142        warnings,
143        errors,
144    };
145
146    Ok((report, path))
147}
148
149fn print_human(report: &StatusReport, path: &std::path::Path) {
150    println!("lean-ctx status  v{}", report.version);
151    println!(
152        "  doctor: {}/{}",
153        report.doctor_compact_passed, report.doctor_compact_total
154    );
155
156    if let Some(setup) = &report.setup_report {
157        println!(
158            "  last setup: {}  success={}",
159            setup.finished_at.to_rfc3339(),
160            setup.success
161        );
162    } else if report.doctor_compact_passed == report.doctor_compact_total {
163        println!("  last setup: (manual install — all checks pass)");
164    } else {
165        println!("  last setup: (none) — run \x1b[1mlean-ctx setup\x1b[0m to configure");
166    }
167
168    let detected = report.mcp_targets.len();
169    let configured = report
170        .mcp_targets
171        .iter()
172        .filter(|t| t.state == "configured")
173        .count();
174    println!("  mcp: {configured}/{detected} configured (detected tools)");
175
176    let rules_detected = report.rules_targets.iter().filter(|t| t.detected).count();
177    let rules_up_to_date = report
178        .rules_targets
179        .iter()
180        .filter(|t| t.detected && t.state == "up_to_date")
181        .count();
182    println!("  rules: {rules_up_to_date}/{rules_detected} up-to-date (detected tools)");
183
184    if !report.warnings.is_empty() {
185        println!("  warnings: {}", report.warnings.len());
186    }
187    if !report.errors.is_empty() {
188        println!("  errors: {}", report.errors.len());
189    }
190    println!("  report saved: {}", path.display());
191}