1#![allow(non_snake_case, unused_variables)]
2
3use std::sync::OnceLock;
4
5use reqwest::Client;
6use serde_json::Value;
7
8use roboticus_core::style::Theme;
9
10pub(crate) const CRT_DRAW_MS: u64 = 4;
11
12#[macro_export]
13macro_rules! println {
14 () => {{ use std::io::Write; std::io::stdout().write_all(b"\n").ok(); std::io::stdout().flush().ok(); }};
15 ($($arg:tt)*) => {{ let __text = format!($($arg)*); theme().typewrite_line_stdout(&__text, CRT_DRAW_MS); }};
16}
17
18#[macro_export]
19macro_rules! eprintln {
20 () => {{ use std::io::Write; std::io::stderr().write_all(b"\n").ok(); }};
21 ($($arg:tt)*) => {{ let __text = format!($($arg)*); theme().typewrite_line(&__text, CRT_DRAW_MS); }};
22}
23
24static THEME: OnceLock<Theme> = OnceLock::new();
25static API_KEY: OnceLock<Option<String>> = OnceLock::new();
26
27pub fn init_api_key(key: Option<String>) {
28 let _ = API_KEY.set(key);
29}
30
31fn api_key() -> Option<&'static str> {
32 API_KEY.get().and_then(|k| k.as_deref())
33}
34
35pub fn http_client() -> Result<Client, Box<dyn std::error::Error>> {
39 let mut builder = Client::builder().timeout(std::time::Duration::from_secs(10));
40 if let Some(key) = api_key() {
41 let mut headers = reqwest::header::HeaderMap::new();
42 headers.insert(
43 "x-api-key",
44 reqwest::header::HeaderValue::from_str(key)
45 .map_err(|e| format!("invalid API key header value: {e}"))?,
46 );
47 builder = builder.default_headers(headers);
48 }
49 Ok(builder.build()?)
50}
51
52pub fn init_theme(color_flag: &str, theme_flag: &str, no_draw: bool, nerdmode: bool) {
53 let t = Theme::from_flags(color_flag, theme_flag);
54 let t = if nerdmode {
55 t.with_nerdmode(true)
56 } else if no_draw {
57 t.with_draw(false)
58 } else {
59 t
60 };
61 let _ = THEME.set(t);
62}
63
64pub fn theme() -> &'static Theme {
65 THEME.get_or_init(Theme::detect)
66}
67
68#[allow(clippy::type_complexity)]
69pub(crate) fn colors() -> (
70 &'static str,
71 &'static str,
72 &'static str,
73 &'static str,
74 &'static str,
75 &'static str,
76 &'static str,
77 &'static str,
78 &'static str,
79) {
80 let t = theme();
81 (
82 t.dim(),
83 t.bold(),
84 t.accent(),
85 t.success(),
86 t.warn(),
87 t.error(),
88 t.info(),
89 t.reset(),
90 t.mono(),
91 )
92}
93
94pub(crate) fn icons() -> (
95 &'static str,
96 &'static str,
97 &'static str,
98 &'static str,
99 &'static str,
100) {
101 let t = theme();
102 (
103 t.icon_ok(),
104 t.icon_action(),
105 t.icon_warn(),
106 t.icon_detail(),
107 t.icon_error(),
108 )
109}
110
111pub struct RoboticusClient {
112 client: Client,
113 base_url: String,
114}
115
116impl RoboticusClient {
117 pub fn new(base_url: &str) -> Result<Self, Box<dyn std::error::Error>> {
118 let mut builder = Client::builder().timeout(std::time::Duration::from_secs(10));
119 if let Some(key) = api_key() {
120 let mut headers = reqwest::header::HeaderMap::new();
121 headers.insert(
122 "x-api-key",
123 reqwest::header::HeaderValue::from_str(key)
124 .map_err(|e| format!("invalid API key header value: {e}"))?,
125 );
126 builder = builder.default_headers(headers);
127 }
128 Ok(Self {
129 client: builder.build()?,
130 base_url: base_url.trim_end_matches('/').to_string(),
131 })
132 }
133 pub(crate) async fn get(&self, path: &str) -> Result<Value, Box<dyn std::error::Error>> {
134 let url = format!("{}{}", self.base_url, path);
135 let resp = self.client.get(&url).send().await?;
136 if !resp.status().is_success() {
137 let status = resp.status();
138 let body = resp.text().await.unwrap_or_default();
139 return Err(format!("HTTP {status}: {body}").into());
140 }
141 Ok(resp.json().await?)
142 }
143 async fn post(&self, path: &str, body: Value) -> Result<Value, Box<dyn std::error::Error>> {
144 let url = format!("{}{}", self.base_url, path);
145 let resp = self.client.post(&url).json(&body).send().await?;
146 if !resp.status().is_success() {
147 let status = resp.status();
148 let text = resp.text().await.unwrap_or_default();
149 return Err(format!("HTTP {status}: {text}").into());
150 }
151 Ok(resp.json().await?)
152 }
153 async fn put(&self, path: &str, body: Value) -> Result<Value, Box<dyn std::error::Error>> {
154 let url = format!("{}{}", self.base_url, path);
155 let resp = self.client.put(&url).json(&body).send().await?;
156 if !resp.status().is_success() {
157 let status = resp.status();
158 let text = resp.text().await.unwrap_or_default();
159 return Err(format!("HTTP {status}: {text}").into());
160 }
161 Ok(resp.json().await?)
162 }
163 fn check_connectivity_hint(e: &dyn std::error::Error) {
164 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
165 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
166 let msg = format!("{e:?}");
167 if msg.contains("Connection refused")
168 || msg.contains("ConnectionRefused")
169 || msg.contains("ConnectError")
170 || msg.contains("connect error")
171 {
172 eprintln!();
173 eprintln!(
174 " {WARN} Is the Roboticus server running? Start it with: {BOLD}roboticus serve{RESET}"
175 );
176 }
177 }
178}
179
180pub(crate) fn heading(text: &str) {
181 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
182 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
183 eprintln!();
184 eprintln!(" {OK} {BOLD}{text}{RESET}");
185 eprintln!(" {DIM}{}{RESET}", "\u{2500}".repeat(60));
186}
187
188pub(crate) fn kv(key: &str, value: &str) {
189 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
190 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
191 eprintln!(" {DIM}{key:<20}{RESET} {value}");
192}
193
194pub(crate) fn kv_accent(key: &str, value: &str) {
195 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
196 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
197 eprintln!(" {DIM}{key:<20}{RESET} {ACCENT}{value}{RESET}");
198}
199
200pub(crate) fn kv_mono(key: &str, value: &str) {
201 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
202 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
203 eprintln!(" {DIM}{key:<20}{RESET} {MONO}{value}{RESET}");
204}
205
206pub(crate) fn badge(text: &str, color: &str) -> String {
207 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
208 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
209 format!("{color}\u{25cf} {text}{RESET}")
210}
211
212pub(crate) fn status_badge(status: &str) -> String {
213 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
214 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
215 match status {
216 "ok" | "running" | "success" => badge(status, GREEN),
217 "sleeping" | "pending" | "warning" => badge(status, YELLOW),
218 "dead" | "error" | "failed" => badge(status, RED),
219 _ => badge(status, DIM),
220 }
221}
222
223pub(crate) fn truncate_id(id: &str, len: usize) -> String {
224 if id.len() > len {
225 format!("{}...", &id[..len])
226 } else {
227 id.to_string()
228 }
229}
230
231pub(crate) fn table_separator(widths: &[usize]) {
232 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
233 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
234 let parts: Vec<String> = widths.iter().map(|w| "\u{2500}".repeat(*w)).collect();
235 eprintln!(" {DIM}\u{251c}{}\u{2524}{RESET}", parts.join("\u{253c}"));
236}
237
238pub(crate) fn table_header(headers: &[&str], widths: &[usize]) {
239 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
240 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
241 let cells: Vec<String> = headers
242 .iter()
243 .zip(widths)
244 .map(|(h, w)| format!("{BOLD}{h:<width$}{RESET}", width = w))
245 .collect();
246 eprintln!(
247 " {DIM}\u{2502}{RESET}{}{DIM}\u{2502}{RESET}",
248 cells.join(&format!("{DIM}\u{2502}{RESET}"))
249 );
250 table_separator(widths);
251}
252
253pub(crate) fn table_row(cells: &[String], widths: &[usize]) {
254 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
255 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
256 let formatted: Vec<String> = cells
257 .iter()
258 .zip(widths)
259 .map(|(c, w)| {
260 let visible_len = strip_ansi_len(c);
261 if visible_len >= *w {
262 c.clone()
263 } else {
264 format!("{c}{}", " ".repeat(w - visible_len))
265 }
266 })
267 .collect();
268 eprintln!(
269 " {DIM}\u{2502}{RESET}{}{DIM}\u{2502}{RESET}",
270 formatted.join(&format!("{DIM}\u{2502}{RESET}"))
271 );
272}
273
274pub(crate) fn strip_ansi_len(s: &str) -> usize {
275 let mut len = 0;
276 let mut in_escape = false;
277 for c in s.chars() {
278 if c == '\x1b' {
279 in_escape = true;
280 } else if in_escape {
281 if c == 'm' {
282 in_escape = false;
283 }
284 } else {
285 len += 1;
286 }
287 }
288 len
289}
290
291pub(crate) fn empty_state(msg: &str) {
292 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
293 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
294 eprintln!(" {DIM}\u{2500}\u{2500} {msg}{RESET}");
295}
296
297pub(crate) fn print_json_section(val: &Value, indent: usize) {
298 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
299 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
300 let pad = " ".repeat(indent);
301 match val {
302 Value::Object(map) => {
303 for (k, v) in map {
304 match v {
305 Value::Object(_) => {
306 eprintln!("{pad}{DIM}{k}:{RESET}");
307 print_json_section(v, indent + 2);
308 }
309 Value::Array(arr) => {
310 let items: Vec<String> =
311 arr.iter().map(|i| format_json_val(i).to_string()).collect();
312 eprintln!(
313 "{pad}{DIM}{k:<22}{RESET} [{MONO}{}{RESET}]",
314 items.join(", ")
315 );
316 }
317 _ => eprintln!("{pad}{DIM}{k:<22}{RESET} {}", format_json_val(v)),
318 }
319 }
320 }
321 _ => eprintln!("{pad}{}", format_json_val(val)),
322 }
323}
324
325pub(crate) fn format_json_val(v: &Value) -> String {
326 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
327 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
328 match v {
329 Value::String(s) => format!("{MONO}{s}{RESET}"),
330 Value::Number(n) => format!("{ACCENT}{n}{RESET}"),
331 Value::Bool(b) => {
332 if *b {
333 format!("{GREEN}{b}{RESET}")
334 } else {
335 format!("{YELLOW}{b}{RESET}")
336 }
337 }
338 Value::Null => format!("{DIM}null{RESET}"),
339 _ => v.to_string(),
340 }
341}
342
343pub(crate) fn urlencoding(s: &str) -> String {
344 s.replace(' ', "%20")
345 .replace('&', "%26")
346 .replace('=', "%3D")
347 .replace('#', "%23")
348}
349
350pub(crate) fn which_binary_in_path(name: &str, path_var: &std::ffi::OsStr) -> Option<String> {
351 let candidates: Vec<String> = {
352 #[cfg(windows)]
353 {
354 let mut c = vec![name.to_string()];
355 let pathext = std::env::var("PATHEXT")
356 .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
357 .to_ascii_lowercase();
358 let has_ext = std::path::Path::new(name).extension().is_some();
359 if !has_ext {
360 for ext in pathext.split(';').filter(|e| !e.is_empty()) {
361 c.push(format!("{name}{ext}"));
362 }
363 }
364 c
365 }
366 #[cfg(not(windows))]
367 {
368 vec![name.to_string()]
369 }
370 };
371
372 for dir in std::env::split_paths(path_var) {
373 #[cfg(windows)]
374 let dir = {
375 let raw = dir.to_string_lossy();
377 std::path::PathBuf::from(raw.trim().trim_matches('"'))
378 };
379
380 for candidate in &candidates {
381 let p = dir.join(candidate);
382 if p.is_file() {
383 return Some(p.display().to_string());
384 }
385 }
386 }
387
388 None
389}
390
391pub(crate) fn which_binary(name: &str) -> Option<String> {
392 let path_var = std::env::var_os("PATH")?;
393 if let Some(found) = which_binary_in_path(name, &path_var) {
394 return Some(found);
395 }
396
397 #[cfg(windows)]
398 {
399 let output = std::process::Command::new("where")
401 .arg(name)
402 .output()
403 .ok()?;
404 if output.status.success() {
405 if let Some(first) = String::from_utf8_lossy(&output.stdout)
406 .lines()
407 .map(str::trim)
408 .find(|line| !line.is_empty())
409 {
410 return Some(first.to_string());
411 }
412 }
413 }
414
415 None
416}
417
418mod admin;
419pub mod defrag;
420mod memory;
421mod schedule;
422mod sessions;
423mod status;
424mod update;
425mod wallet;
426
427pub use admin::*;
428pub use defrag::*;
429pub use memory::*;
430pub use schedule::*;
431pub use sessions::*;
432pub use status::*;
433pub use update::*;
434pub use wallet::*;
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 #[test]
440 fn client_construction() {
441 let c = RoboticusClient::new("http://localhost:18789").unwrap();
442 assert_eq!(c.base_url, "http://localhost:18789");
443 }
444 #[test]
445 fn client_strips_trailing_slash() {
446 let c = RoboticusClient::new("http://localhost:18789/").unwrap();
447 assert_eq!(c.base_url, "http://localhost:18789");
448 }
449 #[test]
450 fn truncate_id_short() {
451 assert_eq!(truncate_id("abc", 10), "abc");
452 }
453 #[test]
454 fn truncate_id_long() {
455 assert_eq!(truncate_id("abcdefghijklmnop", 8), "abcdefgh...");
456 }
457 #[test]
458 fn status_badges() {
459 assert!(status_badge("ok").contains("ok"));
460 assert!(status_badge("dead").contains("dead"));
461 assert!(status_badge("foo").contains("foo"));
462 }
463 #[test]
464 fn strip_ansi_len_works() {
465 assert_eq!(strip_ansi_len("hello"), 5);
466 assert_eq!(strip_ansi_len("\x1b[32mhello\x1b[0m"), 5);
467 }
468 #[test]
469 fn urlencoding_encodes() {
470 assert_eq!(urlencoding("hello world"), "hello%20world");
471 assert_eq!(urlencoding("a&b=c#d"), "a%26b%3Dc%23d");
472 }
473 #[test]
474 fn format_json_val_types() {
475 assert!(format_json_val(&Value::String("test".into())).contains("test"));
476 assert!(format_json_val(&serde_json::json!(42)).contains("42"));
477 assert!(format_json_val(&Value::Null).contains("null"));
478 }
479 #[test]
480 fn which_binary_finds_sh() {
481 let path = std::env::var_os("PATH").expect("PATH must be set");
482 assert!(which_binary_in_path("sh", &path).is_some());
483 }
484 #[test]
485 fn which_binary_returns_none_for_nonsense() {
486 let path = std::env::var_os("PATH").expect("PATH must be set");
487 assert!(which_binary_in_path("__roboticus_nonexistent_binary_98765__", &path).is_none());
488 }
489
490 #[cfg(windows)]
491 #[test]
492 fn which_binary_handles_quoted_windows_path_segment() {
493 use std::ffi::OsString;
494 use std::path::PathBuf;
495
496 let test_dir = std::env::temp_dir().join(format!(
497 "roboticus-quoted-path-test-{}-{}",
498 std::process::id(),
499 "go"
500 ));
501 let _ = std::fs::remove_dir_all(&test_dir);
502 std::fs::create_dir_all(&test_dir).unwrap();
503
504 let go_exe = test_dir.join("go.exe");
505 std::fs::write(&go_exe, b"").unwrap();
506
507 let quoted_path = OsString::from(format!("\"{}\"", test_dir.display()));
508 let found = which_binary_in_path("go", "ed_path).map(PathBuf::from);
509 assert_eq!(found, Some(go_exe.clone()));
510
511 let _ = std::fs::remove_file(go_exe);
512 let _ = std::fs::remove_dir_all(test_dir);
513 }
514
515 #[test]
516 fn format_json_val_bool_true() {
517 let result = format_json_val(&serde_json::json!(true));
518 assert!(result.contains("true"));
519 }
520
521 #[test]
522 fn format_json_val_bool_false() {
523 let result = format_json_val(&serde_json::json!(false));
524 assert!(result.contains("false"));
525 }
526
527 #[test]
528 fn format_json_val_array_uses_to_string() {
529 let result = format_json_val(&serde_json::json!([1, 2, 3]));
530 assert!(result.contains("1"));
531 }
532
533 #[test]
534 fn strip_ansi_len_empty() {
535 assert_eq!(strip_ansi_len(""), 0);
536 }
537
538 #[test]
539 fn strip_ansi_len_only_ansi() {
540 assert_eq!(strip_ansi_len("\x1b[32m\x1b[0m"), 0);
541 }
542
543 #[test]
544 fn status_badge_sleeping() {
545 assert!(status_badge("sleeping").contains("sleeping"));
546 }
547
548 #[test]
549 fn status_badge_pending() {
550 assert!(status_badge("pending").contains("pending"));
551 }
552
553 #[test]
554 fn status_badge_running() {
555 assert!(status_badge("running").contains("running"));
556 }
557
558 #[test]
559 fn badge_contains_text_and_bullet() {
560 let b = badge("running", "\x1b[32m");
561 assert!(b.contains("running"));
562 assert!(b.contains("\u{25cf}"));
563 }
564
565 #[test]
566 fn truncate_id_exact_length() {
567 assert_eq!(truncate_id("abc", 3), "abc");
568 }
569
570 use wiremock::matchers::{method, path};
573 use wiremock::{Mock, MockServer, ResponseTemplate};
574
575 async fn mock_get(server: &MockServer, p: &str, body: serde_json::Value) {
576 Mock::given(method("GET"))
577 .and(path(p))
578 .respond_with(ResponseTemplate::new(200).set_body_json(body))
579 .mount(server)
580 .await;
581 }
582
583 async fn mock_post(server: &MockServer, p: &str, body: serde_json::Value) {
584 Mock::given(method("POST"))
585 .and(path(p))
586 .respond_with(ResponseTemplate::new(200).set_body_json(body))
587 .mount(server)
588 .await;
589 }
590
591 async fn mock_put(server: &MockServer, p: &str, body: serde_json::Value) {
592 Mock::given(method("PUT"))
593 .and(path(p))
594 .respond_with(ResponseTemplate::new(200).set_body_json(body))
595 .mount(server)
596 .await;
597 }
598
599 #[tokio::test]
602 async fn cmd_skills_list_with_skills() {
603 let s = MockServer::start().await;
604 mock_get(&s, "/api/skills", serde_json::json!({
605 "skills": [
606 {"name": "greet", "kind": "builtin", "description": "Says hello", "enabled": true},
607 {"name": "calc", "kind": "gosh", "description": "Math stuff", "enabled": false}
608 ]
609 })).await;
610 super::cmd_skills_list(&s.uri(), false).await.unwrap();
611 }
612
613 #[tokio::test]
614 async fn cmd_skills_list_empty() {
615 let s = MockServer::start().await;
616 mock_get(&s, "/api/skills", serde_json::json!({"skills": []})).await;
617 super::cmd_skills_list(&s.uri(), false).await.unwrap();
618 }
619
620 #[tokio::test]
621 async fn cmd_skills_list_null_skills() {
622 let s = MockServer::start().await;
623 mock_get(&s, "/api/skills", serde_json::json!({})).await;
624 super::cmd_skills_list(&s.uri(), false).await.unwrap();
625 }
626
627 #[tokio::test]
628 async fn cmd_skill_detail_enabled_with_triggers() {
629 let s = MockServer::start().await;
630 mock_get(
631 &s,
632 "/api/skills/greet",
633 serde_json::json!({
634 "id": "greet-001", "name": "greet", "kind": "builtin",
635 "description": "Says hello", "source_path": "/skills/greet.gosh",
636 "content_hash": "abc123", "enabled": true,
637 "triggers_json": "[\"on_start\"]", "script_path": "/scripts/greet.gosh"
638 }),
639 )
640 .await;
641 super::cmd_skill_detail(&s.uri(), "greet", false)
642 .await
643 .unwrap();
644 }
645
646 #[tokio::test]
647 async fn cmd_skill_detail_disabled_no_triggers() {
648 let s = MockServer::start().await;
649 mock_get(
650 &s,
651 "/api/skills/calc",
652 serde_json::json!({
653 "id": "calc-001", "name": "calc", "kind": "gosh",
654 "description": "Math", "source_path": "", "content_hash": "",
655 "enabled": false, "triggers_json": "null", "script_path": "null"
656 }),
657 )
658 .await;
659 super::cmd_skill_detail(&s.uri(), "calc", false)
660 .await
661 .unwrap();
662 }
663
664 #[tokio::test]
665 async fn cmd_skill_detail_enabled_as_int() {
666 let s = MockServer::start().await;
667 mock_get(
668 &s,
669 "/api/skills/x",
670 serde_json::json!({
671 "id": "x", "name": "x", "kind": "builtin",
672 "description": "", "source_path": "", "content_hash": "",
673 "enabled": 1
674 }),
675 )
676 .await;
677 super::cmd_skill_detail(&s.uri(), "x", false).await.unwrap();
678 }
679
680 #[tokio::test]
681 async fn cmd_skills_reload_ok() {
682 let s = MockServer::start().await;
683 mock_post(&s, "/api/skills/reload", serde_json::json!({"ok": true})).await;
684 super::cmd_skills_reload(&s.uri()).await.unwrap();
685 }
686
687 #[tokio::test]
688 async fn cmd_skills_catalog_list_ok() {
689 let s = MockServer::start().await;
690 mock_get(
691 &s,
692 "/api/skills/catalog",
693 serde_json::json!({"items":[{"name":"foo","kind":"instruction","source":"registry"}]}),
694 )
695 .await;
696 super::cmd_skills_catalog_list(&s.uri(), None, false)
697 .await
698 .unwrap();
699 }
700
701 #[tokio::test]
702 async fn cmd_skills_catalog_install_ok() {
703 let s = MockServer::start().await;
704 mock_post(
705 &s,
706 "/api/skills/catalog/install",
707 serde_json::json!({"ok":true,"installed":["foo.md"],"activated":true}),
708 )
709 .await;
710 super::cmd_skills_catalog_install(&s.uri(), &["foo".to_string()], true)
711 .await
712 .unwrap();
713 }
714
715 #[tokio::test]
718 async fn cmd_wallet_full() {
719 let s = MockServer::start().await;
720 mock_get(
721 &s,
722 "/api/wallet/balance",
723 serde_json::json!({
724 "balance": "42.50", "currency": "USDC", "note": "Testnet balance"
725 }),
726 )
727 .await;
728 mock_get(
729 &s,
730 "/api/wallet/address",
731 serde_json::json!({
732 "address": "0xdeadbeef"
733 }),
734 )
735 .await;
736 super::cmd_wallet(&s.uri(), false).await.unwrap();
737 }
738
739 #[tokio::test]
740 async fn cmd_wallet_no_note() {
741 let s = MockServer::start().await;
742 mock_get(
743 &s,
744 "/api/wallet/balance",
745 serde_json::json!({
746 "balance": "0.00", "currency": "USDC"
747 }),
748 )
749 .await;
750 mock_get(
751 &s,
752 "/api/wallet/address",
753 serde_json::json!({
754 "address": "0xabc"
755 }),
756 )
757 .await;
758 super::cmd_wallet(&s.uri(), false).await.unwrap();
759 }
760
761 #[tokio::test]
762 async fn cmd_wallet_address_ok() {
763 let s = MockServer::start().await;
764 mock_get(
765 &s,
766 "/api/wallet/address",
767 serde_json::json!({
768 "address": "0x1234"
769 }),
770 )
771 .await;
772 super::cmd_wallet_address(&s.uri(), false).await.unwrap();
773 }
774
775 #[tokio::test]
776 async fn cmd_wallet_balance_ok() {
777 let s = MockServer::start().await;
778 mock_get(
779 &s,
780 "/api/wallet/balance",
781 serde_json::json!({
782 "balance": "100.00", "currency": "ETH"
783 }),
784 )
785 .await;
786 super::cmd_wallet_balance(&s.uri(), false).await.unwrap();
787 }
788
789 #[tokio::test]
792 async fn cmd_schedule_list_with_jobs() {
793 let s = MockServer::start().await;
794 mock_get(
795 &s,
796 "/api/cron/jobs",
797 serde_json::json!({
798 "jobs": [
799 {
800 "name": "backup", "schedule_kind": "cron", "schedule_expr": "0 * * * *",
801 "last_run_at": "2025-01-01T12:00:00.000Z", "last_status": "ok",
802 "consecutive_errors": 0
803 },
804 {
805 "name": "cleanup", "schedule_kind": "interval", "schedule_expr": "30m",
806 "last_run_at": null, "last_status": "pending",
807 "consecutive_errors": 3
808 }
809 ]
810 }),
811 )
812 .await;
813 super::cmd_schedule_list(&s.uri(), false).await.unwrap();
814 }
815
816 #[tokio::test]
817 async fn cmd_schedule_list_empty() {
818 let s = MockServer::start().await;
819 mock_get(&s, "/api/cron/jobs", serde_json::json!({"jobs": []})).await;
820 super::cmd_schedule_list(&s.uri(), false).await.unwrap();
821 }
822
823 #[tokio::test]
824 async fn cmd_schedule_recover_all_enables_paused_jobs() {
825 let s = MockServer::start().await;
826 mock_get(
827 &s,
828 "/api/cron/jobs",
829 serde_json::json!({
830 "jobs": [
831 {
832 "id": "job-1",
833 "name": "calendar-monitor",
834 "enabled": false,
835 "last_status": "paused_unknown_action",
836 "last_run_at": "2026-02-25 12:00:00"
837 },
838 {
839 "id": "job-2",
840 "name": "healthy-job",
841 "enabled": true,
842 "last_status": "success",
843 "last_run_at": "2026-02-25 12:01:00"
844 }
845 ]
846 }),
847 )
848 .await;
849 mock_put(
850 &s,
851 "/api/cron/jobs/job-1",
852 serde_json::json!({"updated": true}),
853 )
854 .await;
855 super::cmd_schedule_recover(&s.uri(), &[], true, false, false)
856 .await
857 .unwrap();
858 }
859
860 #[tokio::test]
861 async fn cmd_schedule_recover_dry_run_does_not_put() {
862 let s = MockServer::start().await;
863 mock_get(
864 &s,
865 "/api/cron/jobs",
866 serde_json::json!({
867 "jobs": [
868 {
869 "id": "job-1",
870 "name": "calendar-monitor",
871 "enabled": false,
872 "last_status": "paused_unknown_action",
873 "last_run_at": "2026-02-25 12:00:00"
874 }
875 ]
876 }),
877 )
878 .await;
879 super::cmd_schedule_recover(&s.uri(), &[], true, true, false)
880 .await
881 .unwrap();
882 }
883
884 #[tokio::test]
885 async fn cmd_schedule_recover_name_filter() {
886 let s = MockServer::start().await;
887 mock_get(
888 &s,
889 "/api/cron/jobs",
890 serde_json::json!({
891 "jobs": [
892 {
893 "id": "job-1",
894 "name": "calendar-monitor",
895 "enabled": false,
896 "last_status": "paused_unknown_action",
897 "last_run_at": "2026-02-25 12:00:00"
898 },
899 {
900 "id": "job-2",
901 "name": "revenue-check",
902 "enabled": false,
903 "last_status": "paused_unknown_action",
904 "last_run_at": "2026-02-25 12:01:00"
905 }
906 ]
907 }),
908 )
909 .await;
910 mock_put(
911 &s,
912 "/api/cron/jobs/job-2",
913 serde_json::json!({"updated": true}),
914 )
915 .await;
916 super::cmd_schedule_recover(
917 &s.uri(),
918 &["revenue-check".to_string()],
919 false,
920 false,
921 false,
922 )
923 .await
924 .unwrap();
925 }
926
927 #[tokio::test]
930 async fn cmd_memory_working_with_entries() {
931 let s = MockServer::start().await;
932 mock_get(&s, "/api/memory/working/sess-1", serde_json::json!({
933 "entries": [
934 {"id": "e1", "entry_type": "fact", "content": "The sky is blue", "importance": 5}
935 ]
936 })).await;
937 super::cmd_memory(&s.uri(), "working", Some("sess-1"), None, None, false)
938 .await
939 .unwrap();
940 }
941
942 #[tokio::test]
943 async fn cmd_memory_working_empty() {
944 let s = MockServer::start().await;
945 mock_get(
946 &s,
947 "/api/memory/working/sess-2",
948 serde_json::json!({"entries": []}),
949 )
950 .await;
951 super::cmd_memory(&s.uri(), "working", Some("sess-2"), None, None, false)
952 .await
953 .unwrap();
954 }
955
956 #[tokio::test]
957 async fn cmd_memory_working_no_session_errors() {
958 let result = super::cmd_memory("http://unused", "working", None, None, None, false).await;
959 assert!(result.is_err());
960 }
961
962 #[tokio::test]
963 async fn cmd_memory_episodic_with_entries() {
964 let s = MockServer::start().await;
965 Mock::given(method("GET"))
966 .and(path("/api/memory/episodic"))
967 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
968 "entries": [
969 {"id": "ep1", "classification": "conversation", "content": "User asked about weather", "importance": 3}
970 ]
971 })))
972 .mount(&s)
973 .await;
974 super::cmd_memory(&s.uri(), "episodic", None, None, Some(10), false)
975 .await
976 .unwrap();
977 }
978
979 #[tokio::test]
980 async fn cmd_memory_episodic_empty() {
981 let s = MockServer::start().await;
982 Mock::given(method("GET"))
983 .and(path("/api/memory/episodic"))
984 .respond_with(
985 ResponseTemplate::new(200).set_body_json(serde_json::json!({"entries": []})),
986 )
987 .mount(&s)
988 .await;
989 super::cmd_memory(&s.uri(), "episodic", None, None, None, false)
990 .await
991 .unwrap();
992 }
993
994 #[tokio::test]
995 async fn cmd_memory_semantic_with_entries() {
996 let s = MockServer::start().await;
997 mock_get(
998 &s,
999 "/api/memory/semantic/general",
1000 serde_json::json!({
1001 "entries": [
1002 {"key": "favorite_color", "value": "blue", "confidence": 0.95}
1003 ]
1004 }),
1005 )
1006 .await;
1007 super::cmd_memory(&s.uri(), "semantic", None, None, None, false)
1008 .await
1009 .unwrap();
1010 }
1011
1012 #[tokio::test]
1013 async fn cmd_memory_semantic_custom_category() {
1014 let s = MockServer::start().await;
1015 mock_get(
1016 &s,
1017 "/api/memory/semantic/prefs",
1018 serde_json::json!({
1019 "entries": []
1020 }),
1021 )
1022 .await;
1023 super::cmd_memory(&s.uri(), "semantic", Some("prefs"), None, None, false)
1024 .await
1025 .unwrap();
1026 }
1027
1028 #[tokio::test]
1029 async fn cmd_memory_search_with_results() {
1030 let s = MockServer::start().await;
1031 Mock::given(method("GET"))
1032 .and(path("/api/memory/search"))
1033 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1034 "results": ["result one", "result two"]
1035 })))
1036 .mount(&s)
1037 .await;
1038 super::cmd_memory(&s.uri(), "search", None, Some("hello"), None, false)
1039 .await
1040 .unwrap();
1041 }
1042
1043 #[tokio::test]
1044 async fn cmd_memory_search_empty() {
1045 let s = MockServer::start().await;
1046 Mock::given(method("GET"))
1047 .and(path("/api/memory/search"))
1048 .respond_with(
1049 ResponseTemplate::new(200).set_body_json(serde_json::json!({"results": []})),
1050 )
1051 .mount(&s)
1052 .await;
1053 super::cmd_memory(&s.uri(), "search", None, Some("nope"), None, false)
1054 .await
1055 .unwrap();
1056 }
1057
1058 #[tokio::test]
1059 async fn cmd_memory_search_no_query_errors() {
1060 let result = super::cmd_memory("http://unused", "search", None, None, None, false).await;
1061 assert!(result.is_err());
1062 }
1063
1064 #[tokio::test]
1065 async fn cmd_memory_unknown_tier_errors() {
1066 let result = super::cmd_memory("http://unused", "bogus", None, None, None, false).await;
1067 assert!(result.is_err());
1068 }
1069
1070 #[tokio::test]
1073 async fn cmd_sessions_list_with_sessions() {
1074 let s = MockServer::start().await;
1075 mock_get(&s, "/api/sessions", serde_json::json!({
1076 "sessions": [
1077 {"id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z"},
1078 {"id": "s-002", "agent_id": "duncan", "created_at": "2025-01-02T00:00:00Z", "updated_at": "2025-01-02T01:00:00Z"}
1079 ]
1080 })).await;
1081 super::cmd_sessions_list(&s.uri(), false).await.unwrap();
1082 }
1083
1084 #[tokio::test]
1085 async fn cmd_sessions_list_empty() {
1086 let s = MockServer::start().await;
1087 mock_get(&s, "/api/sessions", serde_json::json!({"sessions": []})).await;
1088 super::cmd_sessions_list(&s.uri(), false).await.unwrap();
1089 }
1090
1091 #[tokio::test]
1092 async fn cmd_session_detail_with_messages() {
1093 let s = MockServer::start().await;
1094 mock_get(
1095 &s,
1096 "/api/sessions/s-001",
1097 serde_json::json!({
1098 "id": "s-001", "agent_id": "roboticus",
1099 "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z"
1100 }),
1101 )
1102 .await;
1103 mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1104 "messages": [
1105 {"role": "user", "content": "Hello!", "created_at": "2025-01-01T00:00:05.123Z"},
1106 {"role": "assistant", "content": "Hi there!", "created_at": "2025-01-01T00:00:06.456Z"},
1107 {"role": "system", "content": "Init", "created_at": "2025-01-01T00:00:00Z"},
1108 {"role": "tool", "content": "Result", "created_at": "2025-01-01T00:00:07Z"}
1109 ]
1110 })).await;
1111 super::cmd_session_detail(&s.uri(), "s-001", false)
1112 .await
1113 .unwrap();
1114 }
1115
1116 #[tokio::test]
1117 async fn cmd_session_detail_no_messages() {
1118 let s = MockServer::start().await;
1119 mock_get(
1120 &s,
1121 "/api/sessions/s-002",
1122 serde_json::json!({
1123 "id": "s-002", "agent_id": "roboticus",
1124 "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z"
1125 }),
1126 )
1127 .await;
1128 mock_get(
1129 &s,
1130 "/api/sessions/s-002/messages",
1131 serde_json::json!({"messages": []}),
1132 )
1133 .await;
1134 super::cmd_session_detail(&s.uri(), "s-002", false)
1135 .await
1136 .unwrap();
1137 }
1138
1139 #[tokio::test]
1140 async fn cmd_session_create_ok() {
1141 let s = MockServer::start().await;
1142 mock_post(
1143 &s,
1144 "/api/sessions",
1145 serde_json::json!({"session_id": "new-001"}),
1146 )
1147 .await;
1148 super::cmd_session_create(&s.uri(), "roboticus")
1149 .await
1150 .unwrap();
1151 }
1152
1153 #[tokio::test]
1154 async fn cmd_session_export_json() {
1155 let s = MockServer::start().await;
1156 mock_get(
1157 &s,
1158 "/api/sessions/s-001",
1159 serde_json::json!({
1160 "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1161 }),
1162 )
1163 .await;
1164 mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1165 "messages": [{"role": "user", "content": "Hi", "created_at": "2025-01-01T00:00:01Z"}]
1166 })).await;
1167 let dir = tempfile::tempdir().unwrap();
1168 let out = dir.path().join("export.json");
1169 super::cmd_session_export(&s.uri(), "s-001", "json", Some(out.to_str().unwrap()))
1170 .await
1171 .unwrap();
1172 assert!(out.exists());
1173 let content = std::fs::read_to_string(&out).unwrap();
1174 assert!(content.contains("s-001"));
1175 }
1176
1177 #[tokio::test]
1178 async fn cmd_session_export_markdown() {
1179 let s = MockServer::start().await;
1180 mock_get(
1181 &s,
1182 "/api/sessions/s-001",
1183 serde_json::json!({
1184 "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1185 }),
1186 )
1187 .await;
1188 mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1189 "messages": [{"role": "user", "content": "Hi", "created_at": "2025-01-01T00:00:01Z"}]
1190 })).await;
1191 let dir = tempfile::tempdir().unwrap();
1192 let out = dir.path().join("export.md");
1193 super::cmd_session_export(&s.uri(), "s-001", "markdown", Some(out.to_str().unwrap()))
1194 .await
1195 .unwrap();
1196 assert!(out.exists());
1197 let content = std::fs::read_to_string(&out).unwrap();
1198 assert!(content.contains("# Session"));
1199 }
1200
1201 #[tokio::test]
1202 async fn cmd_session_export_html() {
1203 let s = MockServer::start().await;
1204 mock_get(
1205 &s,
1206 "/api/sessions/s-001",
1207 serde_json::json!({
1208 "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1209 }),
1210 )
1211 .await;
1212 mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1213 "messages": [
1214 {"role": "user", "content": "Hello <world> & \"friends\"", "created_at": "2025-01-01T00:00:01Z"},
1215 {"role": "assistant", "content": "Hi", "created_at": "2025-01-01T00:00:02Z"},
1216 {"role": "system", "content": "Sys", "created_at": "2025-01-01T00:00:00Z"},
1217 {"role": "tool", "content": "Tool output", "created_at": "2025-01-01T00:00:03Z"}
1218 ]
1219 })).await;
1220 let dir = tempfile::tempdir().unwrap();
1221 let out = dir.path().join("export.html");
1222 super::cmd_session_export(&s.uri(), "s-001", "html", Some(out.to_str().unwrap()))
1223 .await
1224 .unwrap();
1225 let content = std::fs::read_to_string(&out).unwrap();
1226 assert!(content.contains("<!DOCTYPE html>"));
1227 assert!(content.contains("&"));
1228 assert!(content.contains("<"));
1229 }
1230
1231 #[tokio::test]
1232 async fn cmd_session_export_to_stdout() {
1233 let s = MockServer::start().await;
1234 mock_get(
1235 &s,
1236 "/api/sessions/s-001",
1237 serde_json::json!({
1238 "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1239 }),
1240 )
1241 .await;
1242 mock_get(
1243 &s,
1244 "/api/sessions/s-001/messages",
1245 serde_json::json!({"messages": []}),
1246 )
1247 .await;
1248 super::cmd_session_export(&s.uri(), "s-001", "json", None)
1249 .await
1250 .unwrap();
1251 }
1252
1253 #[tokio::test]
1254 async fn cmd_session_export_unknown_format() {
1255 let s = MockServer::start().await;
1256 mock_get(
1257 &s,
1258 "/api/sessions/s-001",
1259 serde_json::json!({
1260 "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1261 }),
1262 )
1263 .await;
1264 mock_get(
1265 &s,
1266 "/api/sessions/s-001/messages",
1267 serde_json::json!({"messages": []}),
1268 )
1269 .await;
1270 super::cmd_session_export(&s.uri(), "s-001", "csv", None)
1271 .await
1272 .unwrap();
1273 }
1274
1275 #[tokio::test]
1276 async fn cmd_session_export_not_found() {
1277 let s = MockServer::start().await;
1278 Mock::given(method("GET"))
1279 .and(path("/api/sessions/missing"))
1280 .respond_with(ResponseTemplate::new(404))
1281 .mount(&s)
1282 .await;
1283 super::cmd_session_export(&s.uri(), "missing", "json", None)
1284 .await
1285 .unwrap();
1286 }
1287
1288 #[tokio::test]
1291 async fn cmd_circuit_status_with_providers() {
1292 let s = MockServer::start().await;
1293 mock_get(
1294 &s,
1295 "/api/breaker/status",
1296 serde_json::json!({
1297 "providers": {
1298 "ollama": {"state": "closed"},
1299 "openai": {"state": "open"}
1300 },
1301 "note": "All good"
1302 }),
1303 )
1304 .await;
1305 super::cmd_circuit_status(&s.uri(), false).await.unwrap();
1306 }
1307
1308 #[tokio::test]
1309 async fn cmd_circuit_status_empty_providers() {
1310 let s = MockServer::start().await;
1311 mock_get(
1312 &s,
1313 "/api/breaker/status",
1314 serde_json::json!({"providers": {}}),
1315 )
1316 .await;
1317 super::cmd_circuit_status(&s.uri(), false).await.unwrap();
1318 }
1319
1320 #[tokio::test]
1321 async fn cmd_circuit_status_no_providers_key() {
1322 let s = MockServer::start().await;
1323 mock_get(&s, "/api/breaker/status", serde_json::json!({})).await;
1324 super::cmd_circuit_status(&s.uri(), false).await.unwrap();
1325 }
1326
1327 #[tokio::test]
1328 async fn cmd_circuit_reset_success() {
1329 let s = MockServer::start().await;
1330 mock_get(
1331 &s,
1332 "/api/breaker/status",
1333 serde_json::json!({
1334 "providers": {
1335 "ollama": {"state": "open"},
1336 "moonshot": {"state": "open"}
1337 }
1338 }),
1339 )
1340 .await;
1341 mock_post(
1342 &s,
1343 "/api/breaker/reset/ollama",
1344 serde_json::json!({"ok": true}),
1345 )
1346 .await;
1347 mock_post(
1348 &s,
1349 "/api/breaker/reset/moonshot",
1350 serde_json::json!({"ok": true}),
1351 )
1352 .await;
1353 super::cmd_circuit_reset(&s.uri(), None).await.unwrap();
1354 }
1355
1356 #[tokio::test]
1357 async fn cmd_circuit_reset_server_error() {
1358 let s = MockServer::start().await;
1359 Mock::given(method("GET"))
1360 .and(path("/api/breaker/status"))
1361 .respond_with(ResponseTemplate::new(500))
1362 .mount(&s)
1363 .await;
1364 super::cmd_circuit_reset(&s.uri(), None).await.unwrap();
1365 }
1366
1367 #[tokio::test]
1368 async fn cmd_circuit_reset_single_provider() {
1369 let s = MockServer::start().await;
1370 mock_post(
1371 &s,
1372 "/api/breaker/reset/openai",
1373 serde_json::json!({"ok": true}),
1374 )
1375 .await;
1376 super::cmd_circuit_reset(&s.uri(), Some("openai"))
1377 .await
1378 .unwrap();
1379 }
1380
1381 #[tokio::test]
1384 async fn cmd_agents_list_with_agents() {
1385 let s = MockServer::start().await;
1386 mock_get(
1387 &s,
1388 "/api/agents",
1389 serde_json::json!({
1390 "agents": [
1391 {"id": "roboticus", "name": "Roboticus", "state": "running", "model": "qwen3:8b"},
1392 {"id": "duncan", "name": "Duncan", "state": "sleeping", "model": "gpt-4o"}
1393 ]
1394 }),
1395 )
1396 .await;
1397 super::cmd_agents_list(&s.uri(), false).await.unwrap();
1398 }
1399
1400 #[tokio::test]
1401 async fn cmd_agents_list_empty() {
1402 let s = MockServer::start().await;
1403 mock_get(&s, "/api/agents", serde_json::json!({"agents": []})).await;
1404 super::cmd_agents_list(&s.uri(), false).await.unwrap();
1405 }
1406
1407 #[tokio::test]
1410 async fn cmd_channels_status_with_channels() {
1411 let s = MockServer::start().await;
1412 Mock::given(method("GET"))
1413 .and(path("/api/channels/status"))
1414 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1415 {"name": "telegram", "connected": true, "messages_received": 100, "messages_sent": 50},
1416 {"name": "whatsapp", "connected": false, "messages_received": 0, "messages_sent": 0}
1417 ])))
1418 .mount(&s)
1419 .await;
1420 super::cmd_channels_status(&s.uri(), false).await.unwrap();
1421 }
1422
1423 #[tokio::test]
1424 async fn cmd_channels_status_empty() {
1425 let s = MockServer::start().await;
1426 Mock::given(method("GET"))
1427 .and(path("/api/channels/status"))
1428 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1429 .mount(&s)
1430 .await;
1431 super::cmd_channels_status(&s.uri(), false).await.unwrap();
1432 }
1433
1434 #[tokio::test]
1435 async fn cmd_channels_dead_letter_with_items() {
1436 let s = MockServer::start().await;
1437 Mock::given(method("GET"))
1438 .and(path("/api/channels/dead-letter"))
1439 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1440 "items": [
1441 {"id": "dl-1", "channel": "telegram", "attempts": 5, "max_attempts": 5, "last_error": "blocked"}
1442 ]
1443 })))
1444 .mount(&s)
1445 .await;
1446 super::cmd_channels_dead_letter(&s.uri(), 10, false)
1447 .await
1448 .unwrap();
1449 }
1450
1451 #[tokio::test]
1452 async fn cmd_channels_replay_ok() {
1453 let s = MockServer::start().await;
1454 Mock::given(method("POST"))
1455 .and(path("/api/channels/dead-letter/dl-1/replay"))
1456 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
1457 .mount(&s)
1458 .await;
1459 super::cmd_channels_replay(&s.uri(), "dl-1").await.unwrap();
1460 }
1461
1462 #[tokio::test]
1465 async fn cmd_plugins_list_with_plugins() {
1466 let s = MockServer::start().await;
1467 mock_get(&s, "/api/plugins", serde_json::json!({
1468 "plugins": [
1469 {"name": "weather", "version": "1.0", "status": "active", "tools": [{"name": "get_weather"}]},
1470 {"name": "empty", "version": "0.1", "status": "inactive", "tools": []}
1471 ]
1472 })).await;
1473 super::cmd_plugins_list(&s.uri(), false).await.unwrap();
1474 }
1475
1476 #[tokio::test]
1477 async fn cmd_plugins_list_empty() {
1478 let s = MockServer::start().await;
1479 mock_get(&s, "/api/plugins", serde_json::json!({"plugins": []})).await;
1480 super::cmd_plugins_list(&s.uri(), false).await.unwrap();
1481 }
1482
1483 #[tokio::test]
1484 async fn cmd_plugin_info_found() {
1485 let s = MockServer::start().await;
1486 mock_get(
1487 &s,
1488 "/api/plugins",
1489 serde_json::json!({
1490 "plugins": [
1491 {
1492 "name": "weather", "version": "1.0", "description": "Weather plugin",
1493 "enabled": true, "manifest_path": "/plugins/weather/plugin.toml",
1494 "tools": [{"name": "get_weather"}, {"name": "get_forecast"}]
1495 }
1496 ]
1497 }),
1498 )
1499 .await;
1500 super::cmd_plugin_info(&s.uri(), "weather", false)
1501 .await
1502 .unwrap();
1503 }
1504
1505 #[tokio::test]
1506 async fn cmd_plugin_info_disabled() {
1507 let s = MockServer::start().await;
1508 mock_get(
1509 &s,
1510 "/api/plugins",
1511 serde_json::json!({
1512 "plugins": [{"name": "old", "version": "0.1", "enabled": false}]
1513 }),
1514 )
1515 .await;
1516 super::cmd_plugin_info(&s.uri(), "old", false)
1517 .await
1518 .unwrap();
1519 }
1520
1521 #[tokio::test]
1522 async fn cmd_plugin_info_not_found() {
1523 let s = MockServer::start().await;
1524 mock_get(&s, "/api/plugins", serde_json::json!({"plugins": []})).await;
1525 let result = super::cmd_plugin_info(&s.uri(), "nonexistent", false).await;
1526 assert!(result.is_err());
1527 }
1528
1529 #[tokio::test]
1530 async fn cmd_plugin_toggle_enable() {
1531 let s = MockServer::start().await;
1532 Mock::given(method("PUT"))
1533 .and(path("/api/plugins/weather/toggle"))
1534 .respond_with(ResponseTemplate::new(200))
1535 .mount(&s)
1536 .await;
1537 super::cmd_plugin_toggle(&s.uri(), "weather", true)
1538 .await
1539 .unwrap();
1540 }
1541
1542 #[tokio::test]
1543 async fn cmd_plugin_toggle_disable_fails() {
1544 let s = MockServer::start().await;
1545 Mock::given(method("PUT"))
1546 .and(path("/api/plugins/weather/toggle"))
1547 .respond_with(ResponseTemplate::new(404))
1548 .mount(&s)
1549 .await;
1550 let result = super::cmd_plugin_toggle(&s.uri(), "weather", false).await;
1551 assert!(result.is_err());
1552 }
1553
1554 #[tokio::test]
1555 async fn cmd_plugin_install_missing_source() {
1556 let result = super::cmd_plugin_install("/tmp/roboticus_test_nonexistent_plugin_dir").await;
1557 assert!(result.is_err());
1558 }
1559
1560 #[tokio::test]
1561 async fn cmd_plugin_install_no_manifest() {
1562 let dir = tempfile::tempdir().unwrap();
1563 let result = super::cmd_plugin_install(dir.path().to_str().unwrap()).await;
1564 assert!(result.is_err());
1565 }
1566
1567 #[tokio::test]
1568 async fn cmd_plugin_install_valid() {
1569 let dir = tempfile::tempdir().unwrap();
1570 let manifest = dir.path().join("plugin.toml");
1571 std::fs::write(&manifest, "name = \"test-plugin\"\nversion = \"0.1\"").unwrap();
1572 std::fs::write(dir.path().join("main.gosh"), "print(\"hi\")").unwrap();
1573
1574 let sub = dir.path().join("sub");
1575 std::fs::create_dir(&sub).unwrap();
1576 std::fs::write(sub.join("helper.gosh"), "// helper").unwrap();
1577
1578 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
1579 let _ = super::cmd_plugin_install(dir.path().to_str().unwrap()).await;
1580 }
1581
1582 #[test]
1583 fn cmd_plugin_uninstall_not_found() {
1584 let _home_guard =
1585 crate::test_support::EnvGuard::set("HOME", "/tmp/roboticus_test_uninstall_home");
1586 let result = super::cmd_plugin_uninstall("nonexistent");
1587 assert!(
1588 result.is_err(),
1589 "uninstall of nonexistent plugin should fail"
1590 );
1591 }
1592
1593 #[test]
1594 fn cmd_plugin_uninstall_exists() {
1595 let dir = tempfile::tempdir().unwrap();
1596 let plugins_dir = dir
1597 .path()
1598 .join(".roboticus")
1599 .join("plugins")
1600 .join("myplugin");
1601 std::fs::create_dir_all(&plugins_dir).unwrap();
1602 std::fs::write(plugins_dir.join("plugin.toml"), "name = \"myplugin\"").unwrap();
1603 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
1604 super::cmd_plugin_uninstall("myplugin").unwrap();
1605 assert!(!plugins_dir.exists());
1606 }
1607
1608 #[tokio::test]
1611 async fn cmd_models_list_full_config() {
1612 let s = MockServer::start().await;
1613 mock_get(&s, "/api/config", serde_json::json!({
1614 "models": {
1615 "primary": "qwen3:8b",
1616 "fallbacks": ["gpt-4o", "claude-3"],
1617 "routing": { "mode": "adaptive", "confidence_threshold": 0.85, "local_first": false }
1618 }
1619 })).await;
1620 super::cmd_models_list(&s.uri(), false).await.unwrap();
1621 }
1622
1623 #[tokio::test]
1624 async fn cmd_models_list_minimal_config() {
1625 let s = MockServer::start().await;
1626 mock_get(&s, "/api/config", serde_json::json!({})).await;
1627 super::cmd_models_list(&s.uri(), false).await.unwrap();
1628 }
1629
1630 #[tokio::test]
1631 async fn cmd_models_scan_no_providers() {
1632 let s = MockServer::start().await;
1633 mock_get(&s, "/api/config", serde_json::json!({"providers": {}})).await;
1634 super::cmd_models_scan(&s.uri(), None).await.unwrap();
1635 }
1636
1637 #[tokio::test]
1638 async fn cmd_models_scan_with_local_provider() {
1639 let s = MockServer::start().await;
1640 mock_get(
1641 &s,
1642 "/api/config",
1643 serde_json::json!({
1644 "providers": {
1645 "ollama": {"url": &format!("{}/ollama", s.uri())}
1646 }
1647 }),
1648 )
1649 .await;
1650 Mock::given(method("GET"))
1651 .and(path("/ollama/v1/models"))
1652 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1653 "data": [{"id": "qwen3:8b"}, {"id": "llama3:70b"}]
1654 })))
1655 .mount(&s)
1656 .await;
1657 super::cmd_models_scan(&s.uri(), None).await.unwrap();
1658 }
1659
1660 #[tokio::test]
1661 async fn cmd_models_scan_local_ollama() {
1662 let s = MockServer::start().await;
1663 let _ollama_url = s.uri().to_string().replace("http://", "http://localhost:");
1664 mock_get(
1665 &s,
1666 "/api/config",
1667 serde_json::json!({
1668 "providers": {
1669 "ollama": {"url": &s.uri()}
1670 }
1671 }),
1672 )
1673 .await;
1674 Mock::given(method("GET"))
1675 .and(path("/api/tags"))
1676 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1677 "models": [{"name": "qwen3:8b"}, {"model": "llama3"}]
1678 })))
1679 .mount(&s)
1680 .await;
1681 super::cmd_models_scan(&s.uri(), Some("ollama"))
1682 .await
1683 .unwrap();
1684 }
1685
1686 #[tokio::test]
1687 async fn cmd_models_scan_provider_filter_skips_others() {
1688 let s = MockServer::start().await;
1689 mock_get(
1690 &s,
1691 "/api/config",
1692 serde_json::json!({
1693 "providers": {
1694 "ollama": {"url": "http://localhost:11434"},
1695 "openai": {"url": "https://api.openai.com"}
1696 }
1697 }),
1698 )
1699 .await;
1700 super::cmd_models_scan(&s.uri(), Some("openai"))
1701 .await
1702 .unwrap();
1703 }
1704
1705 #[tokio::test]
1706 async fn cmd_models_scan_empty_url() {
1707 let s = MockServer::start().await;
1708 mock_get(
1709 &s,
1710 "/api/config",
1711 serde_json::json!({
1712 "providers": { "test": {"url": ""} }
1713 }),
1714 )
1715 .await;
1716 super::cmd_models_scan(&s.uri(), None).await.unwrap();
1717 }
1718
1719 #[tokio::test]
1720 async fn cmd_models_scan_error_response() {
1721 let s = MockServer::start().await;
1722 mock_get(
1723 &s,
1724 "/api/config",
1725 serde_json::json!({
1726 "providers": {
1727 "bad": {"url": &s.uri()}
1728 }
1729 }),
1730 )
1731 .await;
1732 Mock::given(method("GET"))
1733 .and(path("/v1/models"))
1734 .respond_with(ResponseTemplate::new(500))
1735 .mount(&s)
1736 .await;
1737 super::cmd_models_scan(&s.uri(), None).await.unwrap();
1738 }
1739
1740 #[tokio::test]
1741 async fn cmd_models_scan_no_models_found() {
1742 let s = MockServer::start().await;
1743 mock_get(
1744 &s,
1745 "/api/config",
1746 serde_json::json!({
1747 "providers": {
1748 "empty": {"url": &s.uri()}
1749 }
1750 }),
1751 )
1752 .await;
1753 Mock::given(method("GET"))
1754 .and(path("/v1/models"))
1755 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1756 .mount(&s)
1757 .await;
1758 super::cmd_models_scan(&s.uri(), None).await.unwrap();
1759 }
1760
1761 #[tokio::test]
1764 async fn cmd_metrics_costs_with_data() {
1765 let s = MockServer::start().await;
1766 mock_get(&s, "/api/stats/costs", serde_json::json!({
1767 "costs": [
1768 {"model": "qwen3:8b", "provider": "ollama", "tokens_in": 100, "tokens_out": 50, "cost": 0.001, "cached": false},
1769 {"model": "gpt-4o", "provider": "openai", "tokens_in": 200, "tokens_out": 100, "cost": 0.01, "cached": true}
1770 ]
1771 })).await;
1772 super::cmd_metrics(&s.uri(), "costs", None, false)
1773 .await
1774 .unwrap();
1775 }
1776
1777 #[tokio::test]
1778 async fn cmd_metrics_costs_empty() {
1779 let s = MockServer::start().await;
1780 mock_get(&s, "/api/stats/costs", serde_json::json!({"costs": []})).await;
1781 super::cmd_metrics(&s.uri(), "costs", None, false)
1782 .await
1783 .unwrap();
1784 }
1785
1786 #[tokio::test]
1787 async fn cmd_metrics_transactions_with_data() {
1788 let s = MockServer::start().await;
1789 Mock::given(method("GET"))
1790 .and(path("/api/stats/transactions"))
1791 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1792 "transactions": [
1793 {"id": "tx-001", "tx_type": "inference", "amount": 0.01, "currency": "USD",
1794 "counterparty": "openai", "created_at": "2025-01-01T12:00:00.000Z"},
1795 {"id": "tx-002", "tx_type": "transfer", "amount": 5.00, "currency": "USDC",
1796 "counterparty": "user", "created_at": "2025-01-01T13:00:00Z"}
1797 ]
1798 })))
1799 .mount(&s)
1800 .await;
1801 super::cmd_metrics(&s.uri(), "transactions", Some(48), false)
1802 .await
1803 .unwrap();
1804 }
1805
1806 #[tokio::test]
1807 async fn cmd_metrics_transactions_empty() {
1808 let s = MockServer::start().await;
1809 Mock::given(method("GET"))
1810 .and(path("/api/stats/transactions"))
1811 .respond_with(
1812 ResponseTemplate::new(200).set_body_json(serde_json::json!({"transactions": []})),
1813 )
1814 .mount(&s)
1815 .await;
1816 super::cmd_metrics(&s.uri(), "transactions", None, false)
1817 .await
1818 .unwrap();
1819 }
1820
1821 #[tokio::test]
1822 async fn cmd_metrics_cache_stats() {
1823 let s = MockServer::start().await;
1824 mock_get(
1825 &s,
1826 "/api/stats/cache",
1827 serde_json::json!({
1828 "hits": 42, "misses": 8, "entries": 100, "hit_rate": 84.0
1829 }),
1830 )
1831 .await;
1832 super::cmd_metrics(&s.uri(), "cache", None, false)
1833 .await
1834 .unwrap();
1835 }
1836
1837 #[tokio::test]
1838 async fn cmd_metrics_unknown_kind() {
1839 let s = MockServer::start().await;
1840 let result = super::cmd_metrics(&s.uri(), "bogus", None, false).await;
1841 assert!(result.is_err());
1842 }
1843
1844 #[test]
1847 fn cmd_completion_bash() {
1848 super::cmd_completion("bash").unwrap();
1849 }
1850
1851 #[test]
1852 fn cmd_completion_zsh() {
1853 super::cmd_completion("zsh").unwrap();
1854 }
1855
1856 #[test]
1857 fn cmd_completion_fish() {
1858 super::cmd_completion("fish").unwrap();
1859 }
1860
1861 #[test]
1862 fn cmd_completion_unknown() {
1863 super::cmd_completion("powershell").unwrap();
1864 }
1865
1866 #[tokio::test]
1869 async fn cmd_logs_static_with_entries() {
1870 let s = MockServer::start().await;
1871 Mock::given(method("GET"))
1872 .and(path("/api/logs"))
1873 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1874 "entries": [
1875 {"timestamp": "2025-01-01T00:00:00Z", "level": "INFO", "message": "Started", "target": "roboticus"},
1876 {"timestamp": "2025-01-01T00:00:01Z", "level": "WARN", "message": "Low memory", "target": "system"},
1877 {"timestamp": "2025-01-01T00:00:02Z", "level": "ERROR", "message": "Failed", "target": "api"},
1878 {"timestamp": "2025-01-01T00:00:03Z", "level": "DEBUG", "message": "Trace", "target": "db"},
1879 {"timestamp": "2025-01-01T00:00:04Z", "level": "TRACE", "message": "Deep", "target": "core"}
1880 ]
1881 })))
1882 .mount(&s)
1883 .await;
1884 super::cmd_logs(&s.uri(), 50, false, "info", false)
1885 .await
1886 .unwrap();
1887 }
1888
1889 #[tokio::test]
1890 async fn cmd_logs_static_empty() {
1891 let s = MockServer::start().await;
1892 Mock::given(method("GET"))
1893 .and(path("/api/logs"))
1894 .respond_with(
1895 ResponseTemplate::new(200).set_body_json(serde_json::json!({"entries": []})),
1896 )
1897 .mount(&s)
1898 .await;
1899 super::cmd_logs(&s.uri(), 10, false, "info", false)
1900 .await
1901 .unwrap();
1902 }
1903
1904 #[tokio::test]
1905 async fn cmd_logs_static_no_entries_key() {
1906 let s = MockServer::start().await;
1907 Mock::given(method("GET"))
1908 .and(path("/api/logs"))
1909 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1910 .mount(&s)
1911 .await;
1912 super::cmd_logs(&s.uri(), 10, false, "info", false)
1913 .await
1914 .unwrap();
1915 }
1916
1917 #[tokio::test]
1918 async fn cmd_logs_server_error_falls_back() {
1919 let s = MockServer::start().await;
1920 Mock::given(method("GET"))
1921 .and(path("/api/logs"))
1922 .respond_with(ResponseTemplate::new(500))
1923 .mount(&s)
1924 .await;
1925 super::cmd_logs(&s.uri(), 10, false, "info", false)
1926 .await
1927 .unwrap();
1928 }
1929
1930 #[test]
1933 fn cmd_security_audit_missing_config() {
1934 super::cmd_security_audit("/tmp/roboticus_test_nonexistent_config.toml", false).unwrap();
1935 }
1936
1937 #[test]
1938 fn cmd_security_audit_clean_config() {
1939 let dir = tempfile::tempdir().unwrap();
1940 let config = dir.path().join("roboticus.toml");
1941 std::fs::write(&config, "[server]\nbind = \"127.0.0.1\"\nport = 18789\n").unwrap();
1942 #[cfg(unix)]
1943 {
1944 use std::os::unix::fs::PermissionsExt;
1945 std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).unwrap();
1946 }
1947 super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
1948 }
1949
1950 #[test]
1951 fn cmd_security_audit_plaintext_keys() {
1952 let dir = tempfile::tempdir().unwrap();
1953 let config = dir.path().join("roboticus.toml");
1954 std::fs::write(&config, "[providers.openai]\napi_key = \"sk-secret123\"\n").unwrap();
1955 #[cfg(unix)]
1956 {
1957 use std::os::unix::fs::PermissionsExt;
1958 std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).unwrap();
1959 }
1960 super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
1961 }
1962
1963 #[test]
1964 fn cmd_security_audit_env_var_keys() {
1965 let dir = tempfile::tempdir().unwrap();
1966 let config = dir.path().join("roboticus.toml");
1967 std::fs::write(&config, "[providers.openai]\napi_key = \"${OPENAI_KEY}\"\n").unwrap();
1968 super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
1969 }
1970
1971 #[test]
1972 fn cmd_security_audit_wildcard_cors() {
1973 let dir = tempfile::tempdir().unwrap();
1974 let config = dir.path().join("roboticus.toml");
1975 std::fs::write(
1976 &config,
1977 "[server]\nbind = \"0.0.0.0\"\n\n[cors]\norigins = \"*\"\n",
1978 )
1979 .unwrap();
1980 super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
1981 }
1982
1983 #[cfg(unix)]
1984 #[test]
1985 fn cmd_security_audit_loose_config_permissions() {
1986 let dir = tempfile::tempdir().unwrap();
1987 let config = dir.path().join("roboticus.toml");
1988 std::fs::write(&config, "[server]\nport = 18789\n").unwrap();
1989 use std::os::unix::fs::PermissionsExt;
1990 std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o644)).unwrap();
1991 super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
1992 }
1993
1994 #[test]
1997 fn cmd_reset_yes_no_db() {
1998 let dir = tempfile::tempdir().unwrap();
1999 let roboticus_dir = dir.path().join(".roboticus");
2000 std::fs::create_dir_all(&roboticus_dir).unwrap();
2001 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2002 super::cmd_reset(true).unwrap();
2003 }
2004
2005 #[test]
2006 fn cmd_reset_yes_with_db_and_config() {
2007 let dir = tempfile::tempdir().unwrap();
2008 let roboticus_dir = dir.path().join(".roboticus");
2009 std::fs::create_dir_all(&roboticus_dir).unwrap();
2010 std::fs::write(roboticus_dir.join("state.db"), "fake db").unwrap();
2011 std::fs::write(roboticus_dir.join("state.db-wal"), "wal").unwrap();
2012 std::fs::write(roboticus_dir.join("state.db-shm"), "shm").unwrap();
2013 std::fs::write(roboticus_dir.join("roboticus.toml"), "[server]").unwrap();
2014 std::fs::create_dir_all(roboticus_dir.join("logs")).unwrap();
2015 std::fs::write(roboticus_dir.join("wallet.json"), "{}").unwrap();
2016 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2017 super::cmd_reset(true).unwrap();
2018 assert!(!roboticus_dir.join("state.db").exists());
2019 assert!(!roboticus_dir.join("roboticus.toml").exists());
2020 assert!(!roboticus_dir.join("logs").exists());
2021 assert!(roboticus_dir.join("wallet.json").exists());
2022 }
2023
2024 #[tokio::test]
2027 async fn cmd_mechanic_gateway_up() {
2028 let s = MockServer::start().await;
2029 mock_get(&s, "/api/health", serde_json::json!({"status": "ok"})).await;
2030 mock_get(&s, "/api/config", serde_json::json!({"models": {}})).await;
2031 mock_get(
2032 &s,
2033 "/api/skills",
2034 serde_json::json!({"skills": [{"id": "s1"}]}),
2035 )
2036 .await;
2037 mock_get(
2038 &s,
2039 "/api/wallet/balance",
2040 serde_json::json!({"balance": "1.00"}),
2041 )
2042 .await;
2043 Mock::given(method("GET"))
2044 .and(path("/api/channels/status"))
2045 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2046 {"connected": true}, {"connected": false}
2047 ])))
2048 .mount(&s)
2049 .await;
2050 let dir = tempfile::tempdir().unwrap();
2051 let roboticus_dir = dir.path().join(".roboticus");
2052 for sub in &["workspace", "skills", "plugins", "logs"] {
2053 std::fs::create_dir_all(roboticus_dir.join(sub)).unwrap();
2054 }
2055 std::fs::write(roboticus_dir.join("roboticus.toml"), "[server]").unwrap();
2056 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2057 let _ = super::cmd_mechanic(&s.uri(), false, false, &[]).await;
2058 }
2059
2060 #[tokio::test]
2061 async fn cmd_mechanic_gateway_down() {
2062 let s = MockServer::start().await;
2063 Mock::given(method("GET"))
2064 .and(path("/api/health"))
2065 .respond_with(ResponseTemplate::new(503))
2066 .mount(&s)
2067 .await;
2068 let dir = tempfile::tempdir().unwrap();
2069 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2070 let _ = super::cmd_mechanic(&s.uri(), false, false, &[]).await;
2071 }
2072
2073 #[tokio::test]
2074 #[ignore = "sets HOME globally, racy with parallel tests — run with --ignored"]
2075 async fn cmd_mechanic_repair_creates_dirs() {
2076 let s = MockServer::start().await;
2077 Mock::given(method("GET"))
2078 .and(path("/api/health"))
2079 .respond_with(
2080 ResponseTemplate::new(200).set_body_json(serde_json::json!({"status": "ok"})),
2081 )
2082 .mount(&s)
2083 .await;
2084 mock_get(&s, "/api/config", serde_json::json!({})).await;
2085 mock_get(&s, "/api/skills", serde_json::json!({"skills": []})).await;
2086 mock_get(&s, "/api/wallet/balance", serde_json::json!({})).await;
2087 Mock::given(method("GET"))
2088 .and(path("/api/channels/status"))
2089 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
2090 .mount(&s)
2091 .await;
2092 let dir = tempfile::tempdir().unwrap();
2093 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2094 let _ = super::cmd_mechanic(&s.uri(), true, false, &[]).await;
2095 assert!(dir.path().join(".roboticus").join("workspace").exists());
2096 }
2097}