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    // Refresh the latest-version cache opportunistically (#563) so the
37    // update hint below never advertises a stale "latest".
38    crate::core::version_check::check_background();
39
40    match build_status_report() {
41        Ok((report, path)) => {
42            let text = serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string());
43            let _ = crate::config_io::write_atomic_with_backup(&path, &text);
44
45            if json {
46                println!("{text}");
47            } else {
48                print_human(&report, &path);
49                if let Some(banner) = crate::core::version_check::get_update_banner() {
50                    println!("\n{banner}");
51                }
52            }
53
54            i32::from(!report.errors.is_empty())
55        }
56        Err(e) => {
57            eprintln!("{e}");
58            2
59        }
60    }
61}
62
63fn build_status_report() -> Result<(StatusReport, std::path::PathBuf), String> {
64    let generated_at = Utc::now();
65    let version = env!("CARGO_PKG_VERSION").to_string();
66    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
67
68    let mut warnings: Vec<String> = Vec::new();
69    let errors: Vec<String> = Vec::new();
70
71    let setup_report = {
72        let path = crate::core::setup_report::SetupReport::default_path()?;
73        if path.exists() {
74            match std::fs::read_to_string(&path) {
75                Ok(s) => match serde_json::from_str::<crate::core::setup_report::SetupReport>(&s) {
76                    Ok(r) => Some(r),
77                    Err(e) => {
78                        warnings.push(format!("setup report parse error: {e}"));
79                        None
80                    }
81                },
82                Err(e) => {
83                    warnings.push(format!("setup report read error: {e}"));
84                    None
85                }
86            }
87        } else {
88            None
89        }
90    };
91
92    let (doctor_compact_passed, doctor_compact_total) = crate::doctor::compact_score();
93
94    // MCP targets (registry based)
95    let targets = crate::core::editor_registry::build_targets(&home);
96    let mut mcp_targets: Vec<McpTargetStatus> = Vec::new();
97    for t in &targets {
98        let detected = t.detect_path.exists();
99        let config_path = t.config_path.to_string_lossy().to_string();
100
101        let state = if !detected {
102            "not_detected".to_string()
103        } else if !t.config_path.exists() {
104            "missing_file".to_string()
105        } else {
106            match std::fs::read_to_string(&t.config_path) {
107                Ok(s) => {
108                    if s.contains("lean-ctx") {
109                        "configured".to_string()
110                    } else {
111                        "missing_entry".to_string()
112                    }
113                }
114                Err(e) => {
115                    warnings.push(format!("mcp config read error for {}: {e}", t.name));
116                    "read_error".to_string()
117                }
118            }
119        };
120
121        if detected {
122            mcp_targets.push(McpTargetStatus {
123                name: t.name.to_string(),
124                detected,
125                config_path,
126                state,
127                note: None,
128            });
129        }
130    }
131
132    if mcp_targets.is_empty() {
133        warnings.push("no supported AI tools detected".to_string());
134    }
135
136    let rules_targets = crate::rules_inject::collect_rules_status(&home);
137
138    let path = crate::core::setup_report::status_report_path()?;
139
140    let report = StatusReport {
141        schema_version: 1,
142        generated_at,
143        version,
144        setup_report,
145        doctor_compact_passed,
146        doctor_compact_total,
147        mcp_targets,
148        rules_targets,
149        warnings,
150        errors,
151    };
152
153    Ok((report, path))
154}
155
156fn print_human(report: &StatusReport, path: &std::path::Path) {
157    println!("lean-ctx status  v{}", report.version);
158    let cfg = crate::core::config::Config::load();
159    if cfg.shadow_mode {
160        println!("  shadow_mode: \x1b[32mactive\x1b[0m");
161    }
162    println!(
163        "  doctor: {}/{}",
164        report.doctor_compact_passed, report.doctor_compact_total
165    );
166
167    if let Some(setup) = &report.setup_report {
168        println!(
169            "  last setup: {}  success={}",
170            setup.finished_at.to_rfc3339(),
171            setup.success
172        );
173    } else if report.doctor_compact_passed == report.doctor_compact_total {
174        println!("  last setup: (manual install — all checks pass)");
175    } else {
176        println!("  last setup: (none) — run \x1b[1mlean-ctx onboard\x1b[0m to configure");
177    }
178
179    let detected = report.mcp_targets.len();
180    let configured = report
181        .mcp_targets
182        .iter()
183        .filter(|t| t.state == "configured")
184        .count();
185    println!("  mcp: {configured}/{detected} configured (detected tools)");
186
187    let rules_detected = report.rules_targets.iter().filter(|t| t.detected).count();
188    let rules_up_to_date = report
189        .rules_targets
190        .iter()
191        .filter(|t| t.detected && t.state == "up_to_date")
192        .count();
193    println!("  rules: {rules_up_to_date}/{rules_detected} up-to-date (detected tools)");
194
195    if !report.warnings.is_empty() {
196        println!("  warnings: {}", report.warnings.len());
197    }
198    if !report.errors.is_empty() {
199        println!("  errors: {}", report.errors.len());
200    }
201    println!("  report saved: {}", path.display());
202}