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 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 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 setup\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}