roboticus-cli 0.11.4

CLI commands and migration engine for the Roboticus agent runtime
Documentation
use super::*;

use roboticus_core::{ProfileEntry, ProfileRegistry, home_dir};

/// `roboticus profile list` — display installed profiles.
pub fn cmd_profile_list() -> Result<(), Box<dyn std::error::Error>> {
    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
    let (OK, ACTION, WARN, DETAIL, ERR) = icons();

    let registry = ProfileRegistry::load()?;
    let profiles = registry.list();

    heading("Installed Profiles");

    if profiles.is_empty() {
        empty_state("No profiles found. Run `roboticus profile create <name>` to add one.");
        return Ok(());
    }

    let widths = [20, 24, 12, 8];
    table_header(&["ID", "Name", "Source", "Active"], &widths);

    for (id, entry) in &profiles {
        let active_str = if entry.active {
            format!("{GREEN}{OK}{RESET}")
        } else {
            String::new()
        };
        let source = entry.source.as_deref().unwrap_or("manual");
        table_row(
            &[
                format!("{ACCENT}{id}{RESET}"),
                entry.name.clone(),
                source.to_string(),
                active_str,
            ],
            &widths,
        );
    }

    eprintln!();
    eprintln!("    {DIM}{} profile(s){RESET}", profiles.len());
    eprintln!();
    Ok(())
}

/// `roboticus profile create <name>` — create a new empty profile.
pub fn cmd_profile_create(
    name: &str,
    display_name: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
    let (OK, ACTION, WARN, DETAIL, ERR) = icons();

    validate_profile_id(name)?;

    let mut registry = ProfileRegistry::load()?;

    if registry.profiles.contains_key(name) {
        return Err(format!("profile '{name}' already exists").into());
    }

    let display = display_name.unwrap_or(name).to_string();
    let rel_path = format!("profiles/{name}");

    let entry = ProfileEntry {
        name: display.clone(),
        description: None,
        path: rel_path.clone(),
        active: false,
        installed_at: Some(chrono_now()),
        version: None,
        source: Some("manual".to_string()),
    };

    registry.profiles.insert(name.to_string(), entry);
    registry.save()?;

    // Create the profile directory tree.
    registry.ensure_profile_dir(name)?;

    eprintln!("  {GREEN}{OK}{RESET} Created profile {ACCENT}{name}{RESET} ({display})");
    eprintln!(
        "  {DIM}Directory: {}{RESET}",
        home_dir().join(".roboticus").join(&rel_path).display()
    );
    eprintln!(
        "  {DIM}Run {BOLD}roboticus profile switch {name}{RESET}{DIM} to activate it.{RESET}"
    );
    eprintln!();
    Ok(())
}

/// `roboticus profile switch <name>` — set the active profile.
pub fn cmd_profile_switch(name: &str) -> Result<(), Box<dyn std::error::Error>> {
    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
    let (OK, ACTION, WARN, DETAIL, ERR) = icons();

    let mut registry = ProfileRegistry::load()?;

    if !registry.profiles.contains_key(name) {
        return Err(format!(
            "profile '{name}' not found. Run `roboticus profile list` to see available profiles."
        )
        .into());
    }

    // Deactivate all, then activate the target.
    for (id, entry) in registry.profiles.iter_mut() {
        entry.active = id == name;
    }

    registry.save()?;

    eprintln!("  {GREEN}{OK}{RESET} Switched to profile {ACCENT}{name}{RESET}");
    eprintln!("  {DIM}Restart the daemon for the change to take effect.{RESET}");
    eprintln!();
    Ok(())
}

/// `roboticus profile delete <name>` — remove a profile (with confirmation).
///
/// `keep_data`: if `true`, the profile directory is left on disk.
pub fn cmd_profile_delete(name: &str, keep_data: bool) -> Result<(), Box<dyn std::error::Error>> {
    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
    let (OK, ACTION, WARN, DETAIL, ERR) = icons();

    if name == "default" {
        return Err("cannot delete the built-in 'default' profile".into());
    }

    let mut registry = ProfileRegistry::load()?;

    let entry = registry
        .profiles
        .get(name)
        .cloned()
        .ok_or_else(|| format!("profile '{name}' not found"))?;

    if entry.active {
        return Err(format!(
            "profile '{name}' is currently active. Switch to a different profile first."
        )
        .into());
    }

    let profile_dir = registry.resolve_config_dir(name)?;

    registry.profiles.remove(name);
    registry.save()?;

    if !keep_data && profile_dir.exists() {
        // Only remove if it is under ~/.roboticus/profiles/ to avoid accidents.
        let safe_root = home_dir().join(".roboticus").join("profiles");
        if profile_dir.starts_with(&safe_root) {
            std::fs::remove_dir_all(&profile_dir).map_err(|e| {
                format!(
                    "removed from registry but could not delete directory {}: {e}",
                    profile_dir.display()
                )
            })?;
            eprintln!("  {GREEN}{OK}{RESET} Deleted profile {ACCENT}{name}{RESET} and its data");
        } else {
            eprintln!(
                "  {WARN} Removed profile {ACCENT}{name}{RESET} from registry. \
                 Directory {} is outside the managed tree and was not removed.",
                profile_dir.display()
            );
        }
    } else {
        eprintln!(
            "  {GREEN}{OK}{RESET} Removed profile {ACCENT}{name}{RESET} from registry \
             (data kept at {})",
            profile_dir.display()
        );
    }

    eprintln!();
    Ok(())
}

// ── Helpers ────────────────────────────────────────────────────────────────

fn validate_profile_id(name: &str) -> Result<(), Box<dyn std::error::Error>> {
    if name.is_empty() {
        return Err("profile name must not be empty".into());
    }
    if !name
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
    {
        return Err(
            "profile name may only contain ASCII letters, digits, hyphens, and underscores".into(),
        );
    }
    Ok(())
}

fn chrono_now() -> String {
    // RFC 3339-ish timestamp without pulling in chrono.
    use std::time::{SystemTime, UNIX_EPOCH};
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    // Format as seconds-since-epoch string; good enough for an "installed_at" audit field.
    // A real timestamp (ISO 8601) would need chrono; we keep it dependency-free.
    format!("{secs}")
}