Skip to main content

roboticus_cli/cli/
profiles.rs

1use super::*;
2
3use roboticus_core::{ProfileEntry, ProfileRegistry, home_dir};
4
5/// `roboticus profile list` — display installed profiles.
6pub fn cmd_profile_list() -> Result<(), Box<dyn std::error::Error>> {
7    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
8    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
9
10    let registry = ProfileRegistry::load()?;
11    let profiles = registry.list();
12
13    heading("Installed Profiles");
14
15    if profiles.is_empty() {
16        empty_state("No profiles found. Run `roboticus profile create <name>` to add one.");
17        return Ok(());
18    }
19
20    let widths = [20, 24, 12, 8];
21    table_header(&["ID", "Name", "Source", "Active"], &widths);
22
23    for (id, entry) in &profiles {
24        let active_str = if entry.active {
25            format!("{GREEN}{OK}{RESET}")
26        } else {
27            String::new()
28        };
29        let source = entry.source.as_deref().unwrap_or("manual");
30        table_row(
31            &[
32                format!("{ACCENT}{id}{RESET}"),
33                entry.name.clone(),
34                source.to_string(),
35                active_str,
36            ],
37            &widths,
38        );
39    }
40
41    eprintln!();
42    eprintln!("    {DIM}{} profile(s){RESET}", profiles.len());
43    eprintln!();
44    Ok(())
45}
46
47/// `roboticus profile create <name>` — create a new empty profile.
48pub fn cmd_profile_create(
49    name: &str,
50    display_name: Option<&str>,
51) -> Result<(), Box<dyn std::error::Error>> {
52    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
53    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
54
55    validate_profile_id(name)?;
56
57    let mut registry = ProfileRegistry::load()?;
58
59    if registry.profiles.contains_key(name) {
60        return Err(format!("profile '{name}' already exists").into());
61    }
62
63    let display = display_name.unwrap_or(name).to_string();
64    let rel_path = format!("profiles/{name}");
65
66    let entry = ProfileEntry {
67        name: display.clone(),
68        description: None,
69        path: rel_path.clone(),
70        active: false,
71        installed_at: Some(chrono_now()),
72        version: None,
73        source: Some("manual".to_string()),
74    };
75
76    registry.profiles.insert(name.to_string(), entry);
77    registry.save()?;
78
79    // Create the profile directory tree.
80    registry.ensure_profile_dir(name)?;
81
82    eprintln!("  {GREEN}{OK}{RESET} Created profile {ACCENT}{name}{RESET} ({display})");
83    eprintln!(
84        "  {DIM}Directory: {}{RESET}",
85        home_dir().join(".roboticus").join(&rel_path).display()
86    );
87    eprintln!(
88        "  {DIM}Run {BOLD}roboticus profile switch {name}{RESET}{DIM} to activate it.{RESET}"
89    );
90    eprintln!();
91    Ok(())
92}
93
94/// `roboticus profile switch <name>` — set the active profile.
95pub fn cmd_profile_switch(name: &str) -> Result<(), Box<dyn std::error::Error>> {
96    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
97    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
98
99    let mut registry = ProfileRegistry::load()?;
100
101    if !registry.profiles.contains_key(name) {
102        return Err(format!(
103            "profile '{name}' not found. Run `roboticus profile list` to see available profiles."
104        )
105        .into());
106    }
107
108    // Deactivate all, then activate the target.
109    for (id, entry) in registry.profiles.iter_mut() {
110        entry.active = id == name;
111    }
112
113    registry.save()?;
114
115    eprintln!("  {GREEN}{OK}{RESET} Switched to profile {ACCENT}{name}{RESET}");
116    eprintln!("  {DIM}Restart the daemon for the change to take effect.{RESET}");
117    eprintln!();
118    Ok(())
119}
120
121/// `roboticus profile delete <name>` — remove a profile (with confirmation).
122///
123/// `keep_data`: if `true`, the profile directory is left on disk.
124pub fn cmd_profile_delete(name: &str, keep_data: bool) -> Result<(), Box<dyn std::error::Error>> {
125    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
126    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
127
128    if name == "default" {
129        return Err("cannot delete the built-in 'default' profile".into());
130    }
131
132    let mut registry = ProfileRegistry::load()?;
133
134    let entry = registry
135        .profiles
136        .get(name)
137        .cloned()
138        .ok_or_else(|| format!("profile '{name}' not found"))?;
139
140    if entry.active {
141        return Err(format!(
142            "profile '{name}' is currently active. Switch to a different profile first."
143        )
144        .into());
145    }
146
147    let profile_dir = registry.resolve_config_dir(name)?;
148
149    registry.profiles.remove(name);
150    registry.save()?;
151
152    if !keep_data && profile_dir.exists() {
153        // Only remove if it is under ~/.roboticus/profiles/ to avoid accidents.
154        let safe_root = home_dir().join(".roboticus").join("profiles");
155        if profile_dir.starts_with(&safe_root) {
156            std::fs::remove_dir_all(&profile_dir).map_err(|e| {
157                format!(
158                    "removed from registry but could not delete directory {}: {e}",
159                    profile_dir.display()
160                )
161            })?;
162            eprintln!("  {GREEN}{OK}{RESET} Deleted profile {ACCENT}{name}{RESET} and its data");
163        } else {
164            eprintln!(
165                "  {WARN} Removed profile {ACCENT}{name}{RESET} from registry. \
166                 Directory {} is outside the managed tree and was not removed.",
167                profile_dir.display()
168            );
169        }
170    } else {
171        eprintln!(
172            "  {GREEN}{OK}{RESET} Removed profile {ACCENT}{name}{RESET} from registry \
173             (data kept at {})",
174            profile_dir.display()
175        );
176    }
177
178    eprintln!();
179    Ok(())
180}
181
182// ── Helpers ────────────────────────────────────────────────────────────────
183
184fn validate_profile_id(name: &str) -> Result<(), Box<dyn std::error::Error>> {
185    if name.is_empty() {
186        return Err("profile name must not be empty".into());
187    }
188    if !name
189        .chars()
190        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
191    {
192        return Err(
193            "profile name may only contain ASCII letters, digits, hyphens, and underscores".into(),
194        );
195    }
196    Ok(())
197}
198
199fn chrono_now() -> String {
200    // RFC 3339-ish timestamp without pulling in chrono.
201    use std::time::{SystemTime, UNIX_EPOCH};
202    let secs = SystemTime::now()
203        .duration_since(UNIX_EPOCH)
204        .map(|d| d.as_secs())
205        .unwrap_or(0);
206    // Format as seconds-since-epoch string; good enough for an "installed_at" audit field.
207    // A real timestamp (ISO 8601) would need chrono; we keep it dependency-free.
208    format!("{secs}")
209}