nebu-ctx 0.6.2

Lean-ctx runtime adapted for the NebuCtx Cloud-backed product.
Documentation
use crate::cloud_client::ServerClient;
use crate::models::ServerConnection;
use crate::{config, core, git_context};
use anyhow::{bail, Context, Result};
use serde_json::json;
use std::io::{self, IsTerminal, Write};

pub fn cmd_connect(args: &[String]) {
    if has_help_flag(args) {
        println!("Usage: nebu-ctx connect [--endpoint <url>] [--token <token>]");
        return;
    }
    if let Err(error) = connect_cloud(args) {
        eprintln!("{error}");
        std::process::exit(1);
    }
}

pub fn cmd_disconnect() {
    if let Err(error) = disconnect_cloud() {
        eprintln!("{error}");
        std::process::exit(1);
    }
}

pub fn cmd_bind() {
    if let Err(error) = bind_current_project() {
        eprintln!("{error}");
        std::process::exit(1);
    }
}

fn connect_cloud(command_args: &[String]) -> Result<()> {
    let saved_connection = config::load_connection().ok().flatten();
    let endpoint = match option_value(command_args, &["--endpoint", "-e", "--url"]) {
        Some(value) => value,
        None => match saved_connection.as_ref() {
            Some(connection) => connection.endpoint.clone(),
            None => prompt_required_value("Cloud URL", None)?,
        },
    };
    let token = match option_value(command_args, &["--token", "-t"]) {
        Some(value) => value,
        None => prompt_required_secret("Cloud token")?,
    };

    let (connection, client) = validate_and_save_connection(&endpoint, &token)?;
    let health = client.health()?;
    output_json(json!({
        "connected": true,
        "endpoint": connection.endpoint,
        "health": health,
    }))
}

fn bind_current_project() -> Result<()> {
    let client = load_or_prompt_cloud_client()?;
    let project_context = git_context::discover_project_context(
        &std::env::current_dir().context("failed to read current directory")?,
    );
    output_json(serde_json::to_value(client.resolve_project(&project_context)?)?)
}

fn disconnect_cloud() -> Result<()> {
    config::clear_connection()?;
    output_json(json!({ "disconnected": true }))
}

fn load_or_prompt_cloud_client() -> Result<ServerClient> {
    if let Ok(client) = ServerClient::load() {
        return Ok(client);
    }

    if !io::stdin().is_terminal() {
        bail!("No cloud connection saved. Run `nebu-ctx connect --endpoint <url> --token <token>`.");
    }

    let endpoint = prompt_required_value("Cloud URL", None)?;
    let token = prompt_required_secret("Cloud token")?;
    let (_, client) = validate_and_save_connection(&endpoint, &token)?;
    Ok(client)
}

fn validate_and_save_connection(endpoint: &str, token: &str) -> Result<(ServerConnection, ServerClient)> {
    let connection = ServerConnection {
        endpoint: config::normalize_server_endpoint(endpoint),
        token: token.trim().to_string(),
    };
    let client = ServerClient::new(connection.clone());
    client.health()?;
    let saved_connection = config::save_connection(&connection.endpoint, &connection.token)?;
    Ok((saved_connection, client))
}

fn prompt_required_value(label: &str, default_value: Option<&str>) -> Result<String> {
    loop {
        print!("{label}");
        if let Some(default_value) = default_value {
            print!(" [{default_value}]");
        }
        print!(": ");
        io::stdout().flush().context("failed to flush prompt")?;

        let mut input = String::new();
        io::stdin()
            .read_line(&mut input)
            .context("failed to read terminal input")?;
        let trimmed = input.trim();
        if !trimmed.is_empty() {
            return Ok(trimmed.to_string());
        }

        if let Some(default_value) = default_value {
            return Ok(default_value.to_string());
        }
    }
}

fn prompt_required_secret(label: &str) -> Result<String> {
    loop {
        let value = rpassword::prompt_password(format!("{label}: "))
            .context("failed to read token from terminal")?;
        if !value.trim().is_empty() {
            return Ok(value);
        }
    }
}

fn option_value(command_args: &[String], flags: &[&str]) -> Option<String> {
    let mut index = 0;
    while index < command_args.len() {
        if flags.contains(&command_args[index].as_str()) {
            return command_args.get(index + 1).cloned();
        }

        index += 1;
    }

    None
}

fn has_help_flag(command_args: &[String]) -> bool {
    command_args
        .iter()
        .any(|argument| matches!(argument.as_str(), "--help" | "-h" | "help"))
}

fn output_json(value: serde_json::Value) -> Result<()> {
    println!("{}", serde_json::to_string_pretty(&value)?);
    Ok(())
}

pub fn cmd_gotchas(args: &[String]) {
    let action = args.first().map(|value| value.as_str()).unwrap_or("list");
    let project_root = std::env::current_dir()
        .map(|path| path.to_string_lossy().to_string())
        .unwrap_or_else(|_| ".".to_string());

    match action {
        "list" | "ls" => {
            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
            println!("{}", store.format_list());
        }
        "clear" => {
            let mut store = core::gotcha_tracker::GotchaStore::load(&project_root);
            let count = store.gotchas.len();
            store.clear();
            let _ = store.save(&project_root);
            println!("Cleared {count} gotchas.");
        }
        "export" => {
            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
            match serde_json::to_string_pretty(&store.gotchas) {
                Ok(json) => println!("{json}"),
                Err(error) => eprintln!("Export failed: {error}"),
            }
        }
        "stats" => {
            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
            println!("Bug Memory Stats:");
            println!("  Active gotchas:      {}", store.gotchas.len());
            println!("  Errors detected:     {}", store.stats.total_errors_detected);
            println!("  Fixes correlated:    {}", store.stats.total_fixes_correlated);
            println!("  Bugs prevented:      {}", store.stats.total_prevented);
            println!("  Promoted to knowledge: {}", store.stats.gotchas_promoted);
            println!("  Decayed/archived:    {}", store.stats.gotchas_decayed);
            println!("  Session logs:        {}", store.error_log.len());
        }
        _ => {
            println!("Usage: nebu-ctx gotchas [list|clear|export|stats]");
        }
    }
}

pub fn cmd_buddy(args: &[String]) {
    let cfg = core::config::Config::load();
    if !cfg.buddy_enabled {
        println!("Buddy is disabled. Enable with: nebu-ctx config buddy_enabled true");
        return;
    }

    let action = args.first().map(|value| value.as_str()).unwrap_or("show");
    let buddy = core::buddy::BuddyState::compute();
    let theme = core::theme::load_theme(&cfg.theme);

    match action {
        "show" | "status" | "stats" => {
            println!("{}", core::buddy::format_buddy_full(&buddy, &theme));
        }
        "ascii" => {
            for line in &buddy.ascii_art {
                println!("  {line}");
            }
        }
        "json" => match serde_json::to_string_pretty(&buddy) {
            Ok(json) => println!("{json}"),
            Err(error) => eprintln!("JSON error: {error}"),
        },
        _ => {
            println!("Usage: nebu-ctx buddy [show|stats|ascii|json]");
        }
    }
}