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 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 {
167 println!(" last setup: (none)");
168 }
169
170 let detected = report.mcp_targets.len();
171 let configured = report
172 .mcp_targets
173 .iter()
174 .filter(|t| t.state == "configured")
175 .count();
176 println!(" mcp: {configured}/{detected} configured (detected tools)");
177
178 let rules_detected = report.rules_targets.iter().filter(|t| t.detected).count();
179 let rules_up_to_date = report
180 .rules_targets
181 .iter()
182 .filter(|t| t.detected && t.state == "up_to_date")
183 .count();
184 println!(" rules: {rules_up_to_date}/{rules_detected} up-to-date (detected tools)");
185
186 if !report.warnings.is_empty() {
187 println!(" warnings: {}", report.warnings.len());
188 }
189 if !report.errors.is_empty() {
190 println!(" errors: {}", report.errors.len());
191 }
192 println!(" report saved: {}", path.display());
193}