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