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 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    // MCP targets (registry based)
99    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 cloud_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,
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(cloud) = &report.cloud_connection {
205        println!("  cloud: connected → {}", cloud.endpoint);
206    } else {
207        println!("  cloud: 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
219/// Print a compact single-line colored status for shell startup.
220/// Called via `nebu-ctx on-brief` from the shell hook's `nebu-ctx-on` function.
221pub 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 cloud_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[36mcloud\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{cloud_part}"
242    );
243}