1use super::*;
2use roboticus_core::RoboticusConfig;
3use roboticus_core::config_utils;
4
5pub 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
54fn 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 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 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
236fn 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 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}