Skip to main content

lean_ctx/cli/
cloud.rs

1use crate::cloud_client::ServerClient;
2use crate::models::ServerConnection;
3use crate::{config, core, git_context};
4use anyhow::{bail, Context, Result};
5use serde_json::json;
6use std::io::{self, IsTerminal, Write};
7
8pub fn cmd_connect(args: &[String]) {
9    if has_help_flag(args) {
10        println!("Usage: nebu-ctx connect [--endpoint <url>] [--token <token>]");
11        return;
12    }
13    if let Err(error) = connect_cloud(args) {
14        eprintln!("{error}");
15        std::process::exit(1);
16    }
17}
18
19pub fn cmd_disconnect() {
20    if let Err(error) = disconnect_cloud() {
21        eprintln!("{error}");
22        std::process::exit(1);
23    }
24}
25
26pub fn cmd_bind() {
27    if let Err(error) = bind_current_project() {
28        eprintln!("{error}");
29        std::process::exit(1);
30    }
31}
32
33fn connect_cloud(command_args: &[String]) -> Result<()> {
34    let saved_connection = config::load_connection().ok().flatten();
35    let endpoint = match option_value(command_args, &["--endpoint", "-e", "--url"]) {
36        Some(value) => value,
37        None => match saved_connection.as_ref() {
38            Some(connection) => connection.endpoint.clone(),
39            None => prompt_required_value("Cloud URL", None)?,
40        },
41    };
42    let token = match option_value(command_args, &["--token", "-t"]) {
43        Some(value) => value,
44        None => prompt_required_secret("Cloud token")?,
45    };
46
47    let (connection, client) = validate_and_save_connection(&endpoint, &token)?;
48    let health = client.health()?;
49    output_json(json!({
50        "connected": true,
51        "endpoint": connection.endpoint,
52        "health": health,
53    }))
54}
55
56fn bind_current_project() -> Result<()> {
57    let client = load_or_prompt_cloud_client()?;
58    let project_context = git_context::discover_project_context(
59        &std::env::current_dir().context("failed to read current directory")?,
60    );
61    output_json(serde_json::to_value(client.resolve_project(&project_context)?)?)
62}
63
64fn disconnect_cloud() -> Result<()> {
65    config::clear_connection()?;
66    output_json(json!({ "disconnected": true }))
67}
68
69fn load_or_prompt_cloud_client() -> Result<ServerClient> {
70    if let Ok(client) = ServerClient::load() {
71        return Ok(client);
72    }
73
74    if !io::stdin().is_terminal() {
75        bail!("No cloud connection saved. Run `nebu-ctx connect --endpoint <url> --token <token>`.");
76    }
77
78    let endpoint = prompt_required_value("Cloud URL", None)?;
79    let token = prompt_required_secret("Cloud token")?;
80    let (_, client) = validate_and_save_connection(&endpoint, &token)?;
81    Ok(client)
82}
83
84fn validate_and_save_connection(endpoint: &str, token: &str) -> Result<(ServerConnection, ServerClient)> {
85    let connection = ServerConnection {
86        endpoint: config::normalize_server_endpoint(endpoint),
87        token: token.trim().to_string(),
88    };
89    let client = ServerClient::new(connection.clone());
90    client.health()?;
91    let saved_connection = config::save_connection(&connection.endpoint, &connection.token)?;
92    Ok((saved_connection, client))
93}
94
95fn prompt_required_value(label: &str, default_value: Option<&str>) -> Result<String> {
96    loop {
97        print!("{label}");
98        if let Some(default_value) = default_value {
99            print!(" [{default_value}]");
100        }
101        print!(": ");
102        io::stdout().flush().context("failed to flush prompt")?;
103
104        let mut input = String::new();
105        io::stdin()
106            .read_line(&mut input)
107            .context("failed to read terminal input")?;
108        let trimmed = input.trim();
109        if !trimmed.is_empty() {
110            return Ok(trimmed.to_string());
111        }
112
113        if let Some(default_value) = default_value {
114            return Ok(default_value.to_string());
115        }
116    }
117}
118
119fn prompt_required_secret(label: &str) -> Result<String> {
120    loop {
121        let value = rpassword::prompt_password(format!("{label}: "))
122            .context("failed to read token from terminal")?;
123        if !value.trim().is_empty() {
124            return Ok(value);
125        }
126    }
127}
128
129fn option_value(command_args: &[String], flags: &[&str]) -> Option<String> {
130    let mut index = 0;
131    while index < command_args.len() {
132        if flags.contains(&command_args[index].as_str()) {
133            return command_args.get(index + 1).cloned();
134        }
135
136        index += 1;
137    }
138
139    None
140}
141
142fn has_help_flag(command_args: &[String]) -> bool {
143    command_args
144        .iter()
145        .any(|argument| matches!(argument.as_str(), "--help" | "-h" | "help"))
146}
147
148fn output_json(value: serde_json::Value) -> Result<()> {
149    println!("{}", serde_json::to_string_pretty(&value)?);
150    Ok(())
151}
152
153pub fn cmd_gotchas(args: &[String]) {
154    let action = args.first().map(|value| value.as_str()).unwrap_or("list");
155    let project_root = std::env::current_dir()
156        .map(|path| path.to_string_lossy().to_string())
157        .unwrap_or_else(|_| ".".to_string());
158
159    match action {
160        "list" | "ls" => {
161            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
162            println!("{}", store.format_list());
163        }
164        "clear" => {
165            let mut store = core::gotcha_tracker::GotchaStore::load(&project_root);
166            let count = store.gotchas.len();
167            store.clear();
168            let _ = store.save(&project_root);
169            println!("Cleared {count} gotchas.");
170        }
171        "export" => {
172            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
173            match serde_json::to_string_pretty(&store.gotchas) {
174                Ok(json) => println!("{json}"),
175                Err(error) => eprintln!("Export failed: {error}"),
176            }
177        }
178        "stats" => {
179            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
180            println!("Bug Memory Stats:");
181            println!("  Active gotchas:      {}", store.gotchas.len());
182            println!("  Errors detected:     {}", store.stats.total_errors_detected);
183            println!("  Fixes correlated:    {}", store.stats.total_fixes_correlated);
184            println!("  Bugs prevented:      {}", store.stats.total_prevented);
185            println!("  Promoted to knowledge: {}", store.stats.gotchas_promoted);
186            println!("  Decayed/archived:    {}", store.stats.gotchas_decayed);
187            println!("  Session logs:        {}", store.error_log.len());
188        }
189        _ => {
190            println!("Usage: nebu-ctx gotchas [list|clear|export|stats]");
191        }
192    }
193}
194
195pub fn cmd_buddy(args: &[String]) {
196    let cfg = core::config::Config::load();
197    if !cfg.buddy_enabled {
198        println!("Buddy is disabled. Enable with: nebu-ctx config buddy_enabled true");
199        return;
200    }
201
202    let action = args.first().map(|value| value.as_str()).unwrap_or("show");
203    let buddy = core::buddy::BuddyState::compute();
204    let theme = core::theme::load_theme(&cfg.theme);
205
206    match action {
207        "show" | "status" | "stats" => {
208            println!("{}", core::buddy::format_buddy_full(&buddy, &theme));
209        }
210        "ascii" => {
211            for line in &buddy.ascii_art {
212                println!("  {line}");
213            }
214        }
215        "json" => match serde_json::to_string_pretty(&buddy) {
216            Ok(json) => println!("{json}"),
217            Err(error) => eprintln!("JSON error: {error}"),
218        },
219        _ => {
220            println!("Usage: nebu-ctx buddy [show|stats|ascii|json]");
221        }
222    }
223}
224