Skip to main content

roboticus_cli/cli/admin/
config.rs

1use super::*;
2use roboticus_core::RoboticusConfig;
3use roboticus_core::config_utils;
4
5// ── Config (show from API) ────────────────────────────────────
6
7pub async fn cmd_config(url: &str, json: bool) -> Result<(), Box<dyn std::error::Error>> {
8    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
9    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
10    let c = RoboticusClient::new(url)?;
11    let data = c.get("/api/config").await.map_err(|e| {
12        RoboticusClient::check_connectivity_hint(&*e);
13        e
14    })?;
15    if json {
16        println!("{}", serde_json::to_string_pretty(&data)?);
17        return Ok(());
18    }
19
20    heading("Configuration");
21
22    let sections = [
23        "agent",
24        "server",
25        "database",
26        "models",
27        "memory",
28        "cache",
29        "treasury",
30        "yield",
31        "wallet",
32        "a2a",
33        "skills",
34        "channels",
35        "circuit_breaker",
36        "providers",
37    ];
38
39    for section in sections {
40        if let Some(val) = data.get(section) {
41            if val.is_null() {
42                continue;
43            }
44            eprintln!();
45            eprintln!("    {DETAIL} {section}{RESET}");
46            print_json_section(val, 6);
47        }
48    }
49
50    eprintln!();
51    Ok(())
52}
53
54// ── Config (get/set/unset from file) ───────────────────────────
55
56fn find_config_file() -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
57    let candidates = [
58        std::path::PathBuf::from("roboticus.toml"),
59        dirs_home().join("roboticus.toml"),
60    ];
61    for c in &candidates {
62        if c.exists() {
63            return Ok(c.clone());
64        }
65    }
66    Err("No roboticus.toml found in current directory or ~/.roboticus/".into())
67}
68
69fn dirs_home() -> std::path::PathBuf {
70    roboticus_core::home_dir().join(".roboticus")
71}
72
73fn navigate_toml<'a>(table: &'a toml::Value, path: &str) -> Option<&'a toml::Value> {
74    let parts: Vec<&str> = path.split('.').collect();
75    let mut current = table;
76    for part in parts {
77        current = current.get(part)?;
78    }
79    Some(current)
80}
81
82fn format_toml_value(v: &toml::Value) -> String {
83    match v {
84        toml::Value::String(s) => s.clone(),
85        toml::Value::Integer(i) => i.to_string(),
86        toml::Value::Float(f) => f.to_string(),
87        toml::Value::Boolean(b) => b.to_string(),
88        toml::Value::Array(a) => {
89            let items: Vec<String> = a.iter().map(format_toml_value).collect();
90            format!("[{}]", items.join(", "))
91        }
92        toml::Value::Table(_) => toml::to_string_pretty(v).unwrap_or_else(|_| format!("{v:?}")),
93        toml::Value::Datetime(d) => d.to_string(),
94    }
95}
96
97fn set_toml_value(
98    table: &mut toml::Value,
99    path: &str,
100    value: &str,
101) -> Result<(), Box<dyn std::error::Error>> {
102    let parts: Vec<&str> = path.split('.').collect();
103    let mut current = table;
104
105    for (i, part) in parts.iter().enumerate() {
106        if i == parts.len() - 1 {
107            if let toml::Value::Table(map) = current {
108                let parsed_value = if matches!(map.get(*part), Some(toml::Value::Array(_))) {
109                    parse_toml_value_for_existing_array(value)
110                } else {
111                    parse_toml_value(value)
112                };
113                map.insert(part.to_string(), parsed_value);
114            }
115        } else {
116            if current.get(part).is_none()
117                && let toml::Value::Table(map) = current
118            {
119                map.insert(part.to_string(), toml::Value::Table(toml::map::Map::new()));
120            }
121            current = current
122                .get_mut(part)
123                .ok_or_else(|| format!("cannot navigate to {part}"))?;
124        }
125    }
126
127    Ok(())
128}
129
130fn remove_toml_key(table: &mut toml::Value, path: &str) -> bool {
131    let parts: Vec<&str> = path.split('.').collect();
132    if parts.len() == 1 {
133        if let toml::Value::Table(map) = table {
134            return map.remove(parts[0]).is_some();
135        }
136        return false;
137    }
138
139    let mut current = table;
140    for part in &parts[..parts.len() - 1] {
141        current = match current.get_mut(part) {
142            Some(v) => v,
143            None => return false,
144        };
145    }
146
147    if let toml::Value::Table(map) = current {
148        parts
149            .last()
150            .map(|p| map.remove(*p).is_some())
151            .unwrap_or(false)
152    } else {
153        false
154    }
155}
156
157fn parse_toml_value_for_existing_array(s: &str) -> toml::Value {
158    let trimmed = s.trim();
159    if trimmed.starts_with('[') || trimmed.starts_with('{') {
160        return parse_toml_value(trimmed);
161    }
162    let items: Vec<toml::Value> = trimmed
163        .split(',')
164        .map(|item| item.trim())
165        .filter(|item| !item.is_empty())
166        .map(parse_toml_value)
167        .collect();
168    toml::Value::Array(items)
169}
170
171fn parse_toml_value(s: &str) -> toml::Value {
172    if let Ok(parsed) = format!("value = {s}").parse::<toml::Table>()
173        && let Some(value) = parsed.get("value")
174    {
175        return value.clone();
176    }
177    if s == "true" {
178        return toml::Value::Boolean(true);
179    }
180    if s == "false" {
181        return toml::Value::Boolean(false);
182    }
183    if let Ok(i) = s.parse::<i64>() {
184        return toml::Value::Integer(i);
185    }
186    if let Ok(f) = s.parse::<f64>() {
187        return toml::Value::Float(f);
188    }
189    if s.starts_with('[') && s.ends_with(']') {
190        let inner = &s[1..s.len() - 1];
191        let items: Vec<toml::Value> = inner
192            .split(',')
193            .map(|item| parse_toml_value(item.trim().trim_matches('"')))
194            .collect();
195        return toml::Value::Array(items);
196    }
197    toml::Value::String(s.trim_matches('"').to_string())
198}
199
200pub async fn cmd_config_get(url: &str, path: &str) -> Result<(), Box<dyn std::error::Error>> {
201    // Try live API first — shows actual runtime config
202    if let Ok(client) = crate::cli::RoboticusClient::new(url)
203        && let Ok(live) = client.get("/api/config").await
204    {
205        let value = navigate_json(&live, path);
206        match value {
207            Some(v) => {
208                println!("{}", serde_json::to_string_pretty(&v)?);
209                return Ok(());
210            }
211            None => {
212                eprintln!("  Key not found: {path}");
213                std::process::exit(1);
214            }
215        }
216    }
217
218    // Fall back to on-disk TOML when server is not running
219    let config_path = find_config_file()?;
220    let contents = std::fs::read_to_string(&config_path)?;
221    let table: toml::Value = contents.parse()?;
222
223    let value = navigate_toml(&table, path);
224    match value {
225        Some(v) => {
226            println!("{}", format_toml_value(v));
227        }
228        None => {
229            eprintln!("  Key not found: {path}");
230            std::process::exit(1);
231        }
232    }
233    Ok(())
234}
235
236/// Navigate a serde_json::Value by dot-separated path
237fn navigate_json<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
238    let parts: Vec<&str> = path.split('.').collect();
239    let mut current = value;
240    for part in parts {
241        current = current.get(part)?;
242    }
243    Some(current)
244}
245
246pub fn cmd_config_set(
247    path: &str,
248    value: &str,
249    file: &str,
250) -> Result<(), Box<dyn std::error::Error>> {
251    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
252    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
253    let contents = std::fs::read_to_string(file).unwrap_or_else(|_| String::new());
254    let mut table: toml::Value = if contents.is_empty() {
255        toml::Value::Table(toml::map::Map::new())
256    } else {
257        contents.parse()?
258    };
259
260    set_toml_value(&mut table, path, value)?;
261
262    let output = toml::to_string_pretty(&table)?;
263    std::fs::write(file, output)?;
264    println!("  {OK} Set {path} = {value} in {file}");
265    Ok(())
266}
267
268pub fn cmd_config_unset(path: &str, file: &str) -> Result<(), Box<dyn std::error::Error>> {
269    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
270    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
271    let contents = std::fs::read_to_string(file)?;
272    let mut table: toml::Value = contents.parse()?;
273
274    if remove_toml_key(&mut table, path) {
275        let output = toml::to_string_pretty(&table)?;
276        std::fs::write(file, output)?;
277        println!("  {OK} Removed {path} from {file}");
278    } else {
279        eprintln!("  Key not found: {path}");
280    }
281    Ok(())
282}
283
284pub fn cmd_config_lint(file: &str) -> Result<(), Box<dyn std::error::Error>> {
285    let (OK, _, _, _, _) = icons();
286    let _cfg = RoboticusConfig::from_file(std::path::Path::new(file))?;
287    println!("  {OK} Config lint passed: {file}");
288    Ok(())
289}
290
291pub fn cmd_config_backup(file: &str) -> Result<(), Box<dyn std::error::Error>> {
292    let (OK, _, _, _, _) = icons();
293    let path = std::path::Path::new(file);
294    // Try to read backup limits from the config itself; fall back to defaults
295    // if the file is missing or unparseable (we still want the backup to succeed).
296    let backups = RoboticusConfig::from_file(path)
297        .map(|c| c.backups)
298        .unwrap_or_default();
299    match config_utils::backup_config_file(path, backups.max_count, backups.max_age_days)? {
300        Some(backup) => println!("  {OK} Backup created: {}", backup.display()),
301        None => println!("  {OK} No backup needed; config file does not exist: {file}"),
302    }
303    Ok(())
304}
305
306pub async fn cmd_config_apply(url: &str, file: &str) -> Result<(), Box<dyn std::error::Error>> {
307    let (OK, _, WARN, _, _) = icons();
308    let cfg = RoboticusConfig::from_file(std::path::Path::new(file))?;
309    let c = RoboticusClient::new(url)?;
310    let payload = serde_json::to_value(cfg)?;
311    match c.put("/api/config", payload).await {
312        Ok(_) => {
313            println!("  {OK} Runtime apply succeeded via /api/config");
314            Ok(())
315        }
316        Err(e) => {
317            eprintln!(
318                "  {WARN} Config file updated, but runtime apply failed (server unavailable?): {e}"
319            );
320            Ok(())
321        }
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn navigate_toml_simple_key() {
331        let toml: toml::Value = "[agent]\nname = \"Duncan\"".parse().unwrap();
332        let result = navigate_toml(&toml, "agent.name");
333        assert_eq!(result.unwrap().as_str().unwrap(), "Duncan");
334    }
335
336    #[test]
337    fn navigate_toml_missing_key() {
338        let toml: toml::Value = "[agent]\nname = \"Duncan\"".parse().unwrap();
339        assert!(navigate_toml(&toml, "agent.missing").is_none());
340    }
341
342    #[test]
343    fn navigate_toml_top_level() {
344        let toml: toml::Value = "port = 8080".parse().unwrap();
345        let result = navigate_toml(&toml, "port");
346        assert_eq!(result.unwrap().as_integer().unwrap(), 8080);
347    }
348
349    #[test]
350    fn navigate_toml_deeply_nested() {
351        let toml: toml::Value = "[a.b.c]\nval = true".parse().unwrap();
352        let result = navigate_toml(&toml, "a.b.c.val");
353        assert!(result.unwrap().as_bool().unwrap());
354    }
355
356    #[test]
357    fn format_toml_value_string() {
358        let v = toml::Value::String("hello".into());
359        assert_eq!(format_toml_value(&v), "hello");
360    }
361
362    #[test]
363    fn format_toml_value_integer() {
364        assert_eq!(format_toml_value(&toml::Value::Integer(42)), "42");
365    }
366
367    #[test]
368    fn format_toml_value_float() {
369        assert_eq!(format_toml_value(&toml::Value::Float(2.72)), "2.72");
370    }
371
372    #[test]
373    fn format_toml_value_bool() {
374        assert_eq!(format_toml_value(&toml::Value::Boolean(true)), "true");
375        assert_eq!(format_toml_value(&toml::Value::Boolean(false)), "false");
376    }
377
378    #[test]
379    fn format_toml_value_array() {
380        let v = toml::Value::Array(vec![
381            toml::Value::String("a".into()),
382            toml::Value::String("b".into()),
383        ]);
384        assert_eq!(format_toml_value(&v), "[a, b]");
385    }
386
387    #[test]
388    fn format_toml_value_table() {
389        let mut map = toml::map::Map::new();
390        map.insert("x".into(), toml::Value::Integer(1));
391        let v = toml::Value::Table(map);
392        let s = format_toml_value(&v);
393        assert!(s.contains("x"));
394    }
395
396    #[test]
397    fn parse_toml_value_bool_true() {
398        assert_eq!(parse_toml_value("true"), toml::Value::Boolean(true));
399    }
400
401    #[test]
402    fn parse_toml_value_bool_false() {
403        assert_eq!(parse_toml_value("false"), toml::Value::Boolean(false));
404    }
405
406    #[test]
407    fn parse_toml_value_integer() {
408        assert_eq!(parse_toml_value("42"), toml::Value::Integer(42));
409    }
410
411    #[test]
412    fn parse_toml_value_negative_integer() {
413        assert_eq!(parse_toml_value("-1"), toml::Value::Integer(-1));
414    }
415
416    #[test]
417    fn parse_toml_value_float() {
418        assert_eq!(parse_toml_value("2.72"), toml::Value::Float(2.72));
419    }
420
421    #[test]
422    fn parse_toml_value_string() {
423        assert_eq!(
424            parse_toml_value("hello"),
425            toml::Value::String("hello".into())
426        );
427    }
428
429    #[test]
430    fn parse_toml_value_quoted_string() {
431        assert_eq!(
432            parse_toml_value("\"hello\""),
433            toml::Value::String("hello".into())
434        );
435    }
436
437    #[test]
438    fn parse_toml_value_array() {
439        let result = parse_toml_value("[a, b, c]");
440        let arr = result.as_array().unwrap();
441        assert_eq!(arr.len(), 3);
442    }
443
444    #[test]
445    fn parse_toml_value_inline_table_array() {
446        let result = parse_toml_value(
447            r#"[{ chain = "ETH", target_contract_address = "0x1", swap_contract_address = "0x2" }]"#,
448        );
449        let arr = result.as_array().unwrap();
450        assert_eq!(arr.len(), 1);
451        let first = arr[0].as_table().unwrap();
452        assert!(first.get("chain").is_some());
453    }
454
455    #[test]
456    fn set_toml_value_existing_key() {
457        let mut table: toml::Value = "[server]\nport = 8080".parse().unwrap();
458        set_toml_value(&mut table, "server.port", "9090").unwrap();
459        assert_eq!(
460            navigate_toml(&table, "server.port")
461                .unwrap()
462                .as_integer()
463                .unwrap(),
464            9090
465        );
466    }
467
468    #[test]
469    fn set_toml_value_new_section() {
470        let mut table = toml::Value::Table(toml::map::Map::new());
471        set_toml_value(&mut table, "new_section.key", "value").unwrap();
472        assert_eq!(
473            navigate_toml(&table, "new_section.key")
474                .unwrap()
475                .as_str()
476                .unwrap(),
477            "value"
478        );
479    }
480
481    #[test]
482    fn set_toml_value_top_level() {
483        let mut table = toml::Value::Table(toml::map::Map::new());
484        set_toml_value(&mut table, "name", "test").unwrap();
485        assert_eq!(table.get("name").unwrap().as_str().unwrap(), "test");
486    }
487
488    #[test]
489    fn set_toml_value_existing_array_accepts_csv() {
490        let mut table: toml::Value = "[channels]\nstartup_announcements = [\"telegram\"]"
491            .parse()
492            .unwrap();
493        set_toml_value(
494            &mut table,
495            "channels.startup_announcements",
496            "telegram,signal,email",
497        )
498        .unwrap();
499        let arr = navigate_toml(&table, "channels.startup_announcements")
500            .unwrap()
501            .as_array()
502            .unwrap();
503        assert_eq!(arr.len(), 3);
504        assert_eq!(arr[1].as_str().unwrap(), "signal");
505    }
506
507    #[test]
508    fn remove_toml_key_existing() {
509        let mut table: toml::Value = "[agent]\nname = \"Duncan\"".parse().unwrap();
510        assert!(remove_toml_key(&mut table, "agent.name"));
511        assert!(navigate_toml(&table, "agent.name").is_none());
512    }
513
514    #[test]
515    fn remove_toml_key_missing() {
516        let mut table: toml::Value = "[agent]\nname = \"Duncan\"".parse().unwrap();
517        assert!(!remove_toml_key(&mut table, "agent.missing"));
518    }
519
520    #[test]
521    fn remove_toml_key_top_level() {
522        let mut table: toml::Value = "port = 8080\nname = \"test\"".parse().unwrap();
523        assert!(remove_toml_key(&mut table, "port"));
524        assert!(table.get("port").is_none());
525        assert!(table.get("name").is_some());
526    }
527
528    #[test]
529    fn remove_toml_key_from_non_table() {
530        let mut table = toml::Value::String("not a table".into());
531        assert!(!remove_toml_key(&mut table, "anything"));
532    }
533
534    #[test]
535    fn dirs_home_contains_roboticus() {
536        let p = dirs_home();
537        assert!(p.to_string_lossy().contains(".roboticus"));
538    }
539}