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