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