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;
493pub mod mcp;
494mod memory;
495mod schedule;
496mod sessions;
497mod status;
498mod update;
499mod wallet;
500
501pub use admin::*;
502pub use defrag::*;
503pub use mcp::*;
504pub use memory::*;
505pub use schedule::*;
506pub use sessions::*;
507pub use status::*;
508pub use update::*;
509pub use wallet::*;
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 #[test]
515 fn client_construction() {
516 let c = RoboticusClient::new("http://localhost:18789").unwrap();
517 assert_eq!(c.base_url, "http://localhost:18789");
518 }
519 #[test]
520 fn client_strips_trailing_slash() {
521 let c = RoboticusClient::new("http://localhost:18789/").unwrap();
522 assert_eq!(c.base_url, "http://localhost:18789");
523 }
524 #[test]
525 fn truncate_id_short() {
526 assert_eq!(truncate_id("abc", 10), "abc");
527 }
528 #[test]
529 fn truncate_id_long() {
530 assert_eq!(truncate_id("abcdefghijklmnop", 8), "abcdefgh...");
531 }
532 #[test]
533 fn status_badges() {
534 assert!(status_badge("ok").contains("ok"));
535 assert!(status_badge("dead").contains("dead"));
536 assert!(status_badge("foo").contains("foo"));
537 }
538 #[test]
539 fn strip_ansi_len_works() {
540 assert_eq!(strip_ansi_len("hello"), 5);
541 assert_eq!(strip_ansi_len("\x1b[32mhello\x1b[0m"), 5);
542 }
543 #[test]
544 fn urlencoding_encodes() {
545 assert_eq!(urlencoding("hello world"), "hello%20world");
546 assert_eq!(urlencoding("a&b=c#d"), "a%26b%3Dc%23d");
547 }
548 #[test]
549 fn format_json_val_types() {
550 assert!(format_json_val(&Value::String("test".into())).contains("test"));
551 assert!(format_json_val(&serde_json::json!(42)).contains("42"));
552 assert!(format_json_val(&Value::Null).contains("null"));
553 }
554 #[test]
555 fn which_binary_finds_sh() {
556 let path = std::env::var_os("PATH").expect("PATH must be set");
557 assert!(which_binary_in_path("sh", &path).is_some());
558 }
559 #[test]
560 fn which_binary_returns_none_for_nonsense() {
561 let path = std::env::var_os("PATH").expect("PATH must be set");
562 assert!(which_binary_in_path("__roboticus_nonexistent_binary_98765__", &path).is_none());
563 }
564
565 #[cfg(windows)]
566 #[test]
567 fn which_binary_handles_quoted_windows_path_segment() {
568 use std::ffi::OsString;
569 use std::path::PathBuf;
570
571 let test_dir = std::env::temp_dir().join(format!(
572 "roboticus-quoted-path-test-{}-{}",
573 std::process::id(),
574 "go"
575 ));
576 let _ = std::fs::remove_dir_all(&test_dir);
577 std::fs::create_dir_all(&test_dir).unwrap();
578
579 let go_exe = test_dir.join("go.exe");
580 std::fs::write(&go_exe, b"").unwrap();
581
582 let quoted_path = OsString::from(format!("\"{}\"", test_dir.display()));
583 let found = which_binary_in_path("go", "ed_path).map(PathBuf::from);
584 assert_eq!(found, Some(go_exe.clone()));
585
586 let _ = std::fs::remove_file(go_exe);
587 let _ = std::fs::remove_dir_all(test_dir);
588 }
589
590 #[test]
591 fn format_json_val_bool_true() {
592 let result = format_json_val(&serde_json::json!(true));
593 assert!(result.contains("true"));
594 }
595
596 #[test]
597 fn format_json_val_bool_false() {
598 let result = format_json_val(&serde_json::json!(false));
599 assert!(result.contains("false"));
600 }
601
602 #[test]
603 fn format_json_val_array_uses_to_string() {
604 let result = format_json_val(&serde_json::json!([1, 2, 3]));
605 assert!(result.contains("1"));
606 }
607
608 #[test]
609 fn strip_ansi_len_empty() {
610 assert_eq!(strip_ansi_len(""), 0);
611 }
612
613 #[test]
614 fn strip_ansi_len_only_ansi() {
615 assert_eq!(strip_ansi_len("\x1b[32m\x1b[0m"), 0);
616 }
617
618 #[test]
619 fn status_badge_sleeping() {
620 assert!(status_badge("sleeping").contains("sleeping"));
621 }
622
623 #[test]
624 fn status_badge_pending() {
625 assert!(status_badge("pending").contains("pending"));
626 }
627
628 #[test]
629 fn status_badge_running() {
630 assert!(status_badge("running").contains("running"));
631 }
632
633 #[test]
634 fn badge_contains_text_and_bullet() {
635 let b = badge("running", "\x1b[32m");
636 assert!(b.contains("running"));
637 assert!(b.contains("\u{25cf}"));
638 }
639
640 #[test]
641 fn truncate_id_exact_length() {
642 assert_eq!(truncate_id("abc", 3), "abc");
643 }
644
645 use wiremock::matchers::{method, path};
648 use wiremock::{Mock, MockServer, ResponseTemplate};
649
650 async fn mock_get(server: &MockServer, p: &str, body: serde_json::Value) {
651 Mock::given(method("GET"))
652 .and(path(p))
653 .respond_with(ResponseTemplate::new(200).set_body_json(body))
654 .mount(server)
655 .await;
656 }
657
658 async fn mock_post(server: &MockServer, p: &str, body: serde_json::Value) {
659 Mock::given(method("POST"))
660 .and(path(p))
661 .respond_with(ResponseTemplate::new(200).set_body_json(body))
662 .mount(server)
663 .await;
664 }
665
666 async fn mock_put(server: &MockServer, p: &str, body: serde_json::Value) {
667 Mock::given(method("PUT"))
668 .and(path(p))
669 .respond_with(ResponseTemplate::new(200).set_body_json(body))
670 .mount(server)
671 .await;
672 }
673
674 #[tokio::test]
677 async fn cmd_skills_list_with_skills() {
678 let s = MockServer::start().await;
679 mock_get(&s, "/api/skills", serde_json::json!({
680 "skills": [
681 {"name": "greet", "kind": "builtin", "description": "Says hello", "enabled": true},
682 {"name": "calc", "kind": "gosh", "description": "Math stuff", "enabled": false}
683 ]
684 })).await;
685 super::cmd_skills_list(&s.uri(), false).await.unwrap();
686 }
687
688 #[tokio::test]
689 async fn cmd_skills_list_empty() {
690 let s = MockServer::start().await;
691 mock_get(&s, "/api/skills", serde_json::json!({"skills": []})).await;
692 super::cmd_skills_list(&s.uri(), false).await.unwrap();
693 }
694
695 #[tokio::test]
696 async fn cmd_skills_list_null_skills() {
697 let s = MockServer::start().await;
698 mock_get(&s, "/api/skills", serde_json::json!({})).await;
699 super::cmd_skills_list(&s.uri(), false).await.unwrap();
700 }
701
702 #[tokio::test]
703 async fn cmd_skill_detail_enabled_with_triggers() {
704 let s = MockServer::start().await;
705 mock_get(
706 &s,
707 "/api/skills/greet",
708 serde_json::json!({
709 "id": "greet-001", "name": "greet", "kind": "builtin",
710 "description": "Says hello", "source_path": "/skills/greet.gosh",
711 "content_hash": "abc123", "enabled": true,
712 "triggers_json": "[\"on_start\"]", "script_path": "/scripts/greet.gosh"
713 }),
714 )
715 .await;
716 super::cmd_skill_detail(&s.uri(), "greet", false)
717 .await
718 .unwrap();
719 }
720
721 #[tokio::test]
722 async fn cmd_skill_detail_disabled_no_triggers() {
723 let s = MockServer::start().await;
724 mock_get(
725 &s,
726 "/api/skills/calc",
727 serde_json::json!({
728 "id": "calc-001", "name": "calc", "kind": "gosh",
729 "description": "Math", "source_path": "", "content_hash": "",
730 "enabled": false, "triggers_json": "null", "script_path": "null"
731 }),
732 )
733 .await;
734 super::cmd_skill_detail(&s.uri(), "calc", false)
735 .await
736 .unwrap();
737 }
738
739 #[tokio::test]
740 async fn cmd_skill_detail_enabled_as_int() {
741 let s = MockServer::start().await;
742 mock_get(
743 &s,
744 "/api/skills/x",
745 serde_json::json!({
746 "id": "x", "name": "x", "kind": "builtin",
747 "description": "", "source_path": "", "content_hash": "",
748 "enabled": 1
749 }),
750 )
751 .await;
752 super::cmd_skill_detail(&s.uri(), "x", false).await.unwrap();
753 }
754
755 #[tokio::test]
756 async fn cmd_skills_reload_ok() {
757 let s = MockServer::start().await;
758 mock_post(&s, "/api/skills/reload", serde_json::json!({"ok": true})).await;
759 super::cmd_skills_reload(&s.uri()).await.unwrap();
760 }
761
762 #[tokio::test]
763 async fn cmd_skills_catalog_list_ok() {
764 let s = MockServer::start().await;
765 mock_get(
766 &s,
767 "/api/skills/catalog",
768 serde_json::json!({"items":[{"name":"foo","kind":"instruction","source":"registry"}]}),
769 )
770 .await;
771 super::cmd_skills_catalog_list(&s.uri(), None, false)
772 .await
773 .unwrap();
774 }
775
776 #[tokio::test]
777 async fn cmd_skills_catalog_install_ok() {
778 let s = MockServer::start().await;
779 mock_post(
780 &s,
781 "/api/skills/catalog/install",
782 serde_json::json!({"ok":true,"installed":["foo.md"],"activated":true}),
783 )
784 .await;
785 super::cmd_skills_catalog_install(&s.uri(), &["foo".to_string()], true)
786 .await
787 .unwrap();
788 }
789
790 #[tokio::test]
793 async fn cmd_wallet_full() {
794 let s = MockServer::start().await;
795 mock_get(
796 &s,
797 "/api/wallet/balance",
798 serde_json::json!({
799 "balance": "42.50", "currency": "USDC", "note": "Testnet balance"
800 }),
801 )
802 .await;
803 mock_get(
804 &s,
805 "/api/wallet/address",
806 serde_json::json!({
807 "address": "0xdeadbeef"
808 }),
809 )
810 .await;
811 super::cmd_wallet(&s.uri(), false).await.unwrap();
812 }
813
814 #[tokio::test]
815 async fn cmd_wallet_no_note() {
816 let s = MockServer::start().await;
817 mock_get(
818 &s,
819 "/api/wallet/balance",
820 serde_json::json!({
821 "balance": "0.00", "currency": "USDC"
822 }),
823 )
824 .await;
825 mock_get(
826 &s,
827 "/api/wallet/address",
828 serde_json::json!({
829 "address": "0xabc"
830 }),
831 )
832 .await;
833 super::cmd_wallet(&s.uri(), false).await.unwrap();
834 }
835
836 #[tokio::test]
837 async fn cmd_wallet_address_ok() {
838 let s = MockServer::start().await;
839 mock_get(
840 &s,
841 "/api/wallet/address",
842 serde_json::json!({
843 "address": "0x1234"
844 }),
845 )
846 .await;
847 super::cmd_wallet_address(&s.uri(), false).await.unwrap();
848 }
849
850 #[tokio::test]
851 async fn cmd_wallet_balance_ok() {
852 let s = MockServer::start().await;
853 mock_get(
854 &s,
855 "/api/wallet/balance",
856 serde_json::json!({
857 "balance": "100.00", "currency": "ETH"
858 }),
859 )
860 .await;
861 super::cmd_wallet_balance(&s.uri(), false).await.unwrap();
862 }
863
864 #[tokio::test]
867 async fn cmd_schedule_list_with_jobs() {
868 let s = MockServer::start().await;
869 mock_get(
870 &s,
871 "/api/cron/jobs",
872 serde_json::json!({
873 "jobs": [
874 {
875 "name": "backup", "schedule_kind": "cron", "schedule_expr": "0 * * * *",
876 "last_run_at": "2025-01-01T12:00:00.000Z", "last_status": "ok",
877 "consecutive_errors": 0
878 },
879 {
880 "name": "cleanup", "schedule_kind": "interval", "schedule_expr": "30m",
881 "last_run_at": null, "last_status": "pending",
882 "consecutive_errors": 3
883 }
884 ]
885 }),
886 )
887 .await;
888 super::cmd_schedule_list(&s.uri(), false).await.unwrap();
889 }
890
891 #[tokio::test]
892 async fn cmd_schedule_list_empty() {
893 let s = MockServer::start().await;
894 mock_get(&s, "/api/cron/jobs", serde_json::json!({"jobs": []})).await;
895 super::cmd_schedule_list(&s.uri(), false).await.unwrap();
896 }
897
898 #[tokio::test]
899 async fn cmd_schedule_recover_all_enables_paused_jobs() {
900 let s = MockServer::start().await;
901 mock_get(
902 &s,
903 "/api/cron/jobs",
904 serde_json::json!({
905 "jobs": [
906 {
907 "id": "job-1",
908 "name": "calendar-monitor",
909 "enabled": false,
910 "last_status": "paused_unknown_action",
911 "last_run_at": "2026-02-25 12:00:00"
912 },
913 {
914 "id": "job-2",
915 "name": "healthy-job",
916 "enabled": true,
917 "last_status": "success",
918 "last_run_at": "2026-02-25 12:01:00"
919 }
920 ]
921 }),
922 )
923 .await;
924 mock_put(
925 &s,
926 "/api/cron/jobs/job-1",
927 serde_json::json!({"updated": true}),
928 )
929 .await;
930 super::cmd_schedule_recover(&s.uri(), &[], true, false, false)
931 .await
932 .unwrap();
933 }
934
935 #[tokio::test]
936 async fn cmd_schedule_recover_dry_run_does_not_put() {
937 let s = MockServer::start().await;
938 mock_get(
939 &s,
940 "/api/cron/jobs",
941 serde_json::json!({
942 "jobs": [
943 {
944 "id": "job-1",
945 "name": "calendar-monitor",
946 "enabled": false,
947 "last_status": "paused_unknown_action",
948 "last_run_at": "2026-02-25 12:00:00"
949 }
950 ]
951 }),
952 )
953 .await;
954 super::cmd_schedule_recover(&s.uri(), &[], true, true, false)
955 .await
956 .unwrap();
957 }
958
959 #[tokio::test]
960 async fn cmd_schedule_recover_name_filter() {
961 let s = MockServer::start().await;
962 mock_get(
963 &s,
964 "/api/cron/jobs",
965 serde_json::json!({
966 "jobs": [
967 {
968 "id": "job-1",
969 "name": "calendar-monitor",
970 "enabled": false,
971 "last_status": "paused_unknown_action",
972 "last_run_at": "2026-02-25 12:00:00"
973 },
974 {
975 "id": "job-2",
976 "name": "revenue-check",
977 "enabled": false,
978 "last_status": "paused_unknown_action",
979 "last_run_at": "2026-02-25 12:01:00"
980 }
981 ]
982 }),
983 )
984 .await;
985 mock_put(
986 &s,
987 "/api/cron/jobs/job-2",
988 serde_json::json!({"updated": true}),
989 )
990 .await;
991 super::cmd_schedule_recover(
992 &s.uri(),
993 &["revenue-check".to_string()],
994 false,
995 false,
996 false,
997 )
998 .await
999 .unwrap();
1000 }
1001
1002 #[tokio::test]
1005 async fn cmd_memory_working_with_entries() {
1006 let s = MockServer::start().await;
1007 mock_get(&s, "/api/memory/working/sess-1", serde_json::json!({
1008 "entries": [
1009 {"id": "e1", "entry_type": "fact", "content": "The sky is blue", "importance": 5}
1010 ]
1011 })).await;
1012 super::cmd_memory(&s.uri(), "working", Some("sess-1"), None, None, false)
1013 .await
1014 .unwrap();
1015 }
1016
1017 #[tokio::test]
1018 async fn cmd_memory_working_empty() {
1019 let s = MockServer::start().await;
1020 mock_get(
1021 &s,
1022 "/api/memory/working/sess-2",
1023 serde_json::json!({"entries": []}),
1024 )
1025 .await;
1026 super::cmd_memory(&s.uri(), "working", Some("sess-2"), None, None, false)
1027 .await
1028 .unwrap();
1029 }
1030
1031 #[tokio::test]
1032 async fn cmd_memory_working_no_session_errors() {
1033 let result = super::cmd_memory("http://unused", "working", None, None, None, false).await;
1034 assert!(result.is_err());
1035 }
1036
1037 #[tokio::test]
1038 async fn cmd_memory_episodic_with_entries() {
1039 let s = MockServer::start().await;
1040 Mock::given(method("GET"))
1041 .and(path("/api/memory/episodic"))
1042 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1043 "entries": [
1044 {"id": "ep1", "classification": "conversation", "content": "User asked about weather", "importance": 3}
1045 ]
1046 })))
1047 .mount(&s)
1048 .await;
1049 super::cmd_memory(&s.uri(), "episodic", None, None, Some(10), false)
1050 .await
1051 .unwrap();
1052 }
1053
1054 #[tokio::test]
1055 async fn cmd_memory_episodic_empty() {
1056 let s = MockServer::start().await;
1057 Mock::given(method("GET"))
1058 .and(path("/api/memory/episodic"))
1059 .respond_with(
1060 ResponseTemplate::new(200).set_body_json(serde_json::json!({"entries": []})),
1061 )
1062 .mount(&s)
1063 .await;
1064 super::cmd_memory(&s.uri(), "episodic", None, None, None, false)
1065 .await
1066 .unwrap();
1067 }
1068
1069 #[tokio::test]
1070 async fn cmd_memory_semantic_with_entries() {
1071 let s = MockServer::start().await;
1072 mock_get(
1073 &s,
1074 "/api/memory/semantic/general",
1075 serde_json::json!({
1076 "entries": [
1077 {"key": "favorite_color", "value": "blue", "confidence": 0.95}
1078 ]
1079 }),
1080 )
1081 .await;
1082 super::cmd_memory(&s.uri(), "semantic", None, None, None, false)
1083 .await
1084 .unwrap();
1085 }
1086
1087 #[tokio::test]
1088 async fn cmd_memory_semantic_custom_category() {
1089 let s = MockServer::start().await;
1090 mock_get(
1091 &s,
1092 "/api/memory/semantic/prefs",
1093 serde_json::json!({
1094 "entries": []
1095 }),
1096 )
1097 .await;
1098 super::cmd_memory(&s.uri(), "semantic", Some("prefs"), None, None, false)
1099 .await
1100 .unwrap();
1101 }
1102
1103 #[tokio::test]
1104 async fn cmd_memory_search_with_results() {
1105 let s = MockServer::start().await;
1106 Mock::given(method("GET"))
1107 .and(path("/api/memory/search"))
1108 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1109 "results": ["result one", "result two"]
1110 })))
1111 .mount(&s)
1112 .await;
1113 super::cmd_memory(&s.uri(), "search", None, Some("hello"), None, false)
1114 .await
1115 .unwrap();
1116 }
1117
1118 #[tokio::test]
1119 async fn cmd_memory_search_empty() {
1120 let s = MockServer::start().await;
1121 Mock::given(method("GET"))
1122 .and(path("/api/memory/search"))
1123 .respond_with(
1124 ResponseTemplate::new(200).set_body_json(serde_json::json!({"results": []})),
1125 )
1126 .mount(&s)
1127 .await;
1128 super::cmd_memory(&s.uri(), "search", None, Some("nope"), None, false)
1129 .await
1130 .unwrap();
1131 }
1132
1133 #[tokio::test]
1134 async fn cmd_memory_search_no_query_errors() {
1135 let result = super::cmd_memory("http://unused", "search", None, None, None, false).await;
1136 assert!(result.is_err());
1137 }
1138
1139 #[tokio::test]
1140 async fn cmd_memory_unknown_tier_errors() {
1141 let result = super::cmd_memory("http://unused", "bogus", None, None, None, false).await;
1142 assert!(result.is_err());
1143 }
1144
1145 #[tokio::test]
1148 async fn cmd_sessions_list_with_sessions() {
1149 let s = MockServer::start().await;
1150 mock_get(&s, "/api/sessions", serde_json::json!({
1151 "sessions": [
1152 {"id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z"},
1153 {"id": "s-002", "agent_id": "duncan", "created_at": "2025-01-02T00:00:00Z", "updated_at": "2025-01-02T01:00:00Z"}
1154 ]
1155 })).await;
1156 super::cmd_sessions_list(&s.uri(), false).await.unwrap();
1157 }
1158
1159 #[tokio::test]
1160 async fn cmd_sessions_list_empty() {
1161 let s = MockServer::start().await;
1162 mock_get(&s, "/api/sessions", serde_json::json!({"sessions": []})).await;
1163 super::cmd_sessions_list(&s.uri(), false).await.unwrap();
1164 }
1165
1166 #[tokio::test]
1167 async fn cmd_session_detail_with_messages() {
1168 let s = MockServer::start().await;
1169 mock_get(
1170 &s,
1171 "/api/sessions/s-001",
1172 serde_json::json!({
1173 "id": "s-001", "agent_id": "roboticus",
1174 "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z"
1175 }),
1176 )
1177 .await;
1178 mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1179 "messages": [
1180 {"role": "user", "content": "Hello!", "created_at": "2025-01-01T00:00:05.123Z"},
1181 {"role": "assistant", "content": "Hi there!", "created_at": "2025-01-01T00:00:06.456Z"},
1182 {"role": "system", "content": "Init", "created_at": "2025-01-01T00:00:00Z"},
1183 {"role": "tool", "content": "Result", "created_at": "2025-01-01T00:00:07Z"}
1184 ]
1185 })).await;
1186 super::cmd_session_detail(&s.uri(), "s-001", false)
1187 .await
1188 .unwrap();
1189 }
1190
1191 #[tokio::test]
1192 async fn cmd_session_detail_no_messages() {
1193 let s = MockServer::start().await;
1194 mock_get(
1195 &s,
1196 "/api/sessions/s-002",
1197 serde_json::json!({
1198 "id": "s-002", "agent_id": "roboticus",
1199 "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T01:00:00Z"
1200 }),
1201 )
1202 .await;
1203 mock_get(
1204 &s,
1205 "/api/sessions/s-002/messages",
1206 serde_json::json!({"messages": []}),
1207 )
1208 .await;
1209 super::cmd_session_detail(&s.uri(), "s-002", false)
1210 .await
1211 .unwrap();
1212 }
1213
1214 #[tokio::test]
1215 async fn cmd_session_create_ok() {
1216 let s = MockServer::start().await;
1217 mock_post(
1218 &s,
1219 "/api/sessions",
1220 serde_json::json!({"session_id": "new-001"}),
1221 )
1222 .await;
1223 super::cmd_session_create(&s.uri(), "roboticus")
1224 .await
1225 .unwrap();
1226 }
1227
1228 #[tokio::test]
1229 async fn cmd_session_export_json() {
1230 let s = MockServer::start().await;
1231 mock_get(
1232 &s,
1233 "/api/sessions/s-001",
1234 serde_json::json!({
1235 "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1236 }),
1237 )
1238 .await;
1239 mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1240 "messages": [{"role": "user", "content": "Hi", "created_at": "2025-01-01T00:00:01Z"}]
1241 })).await;
1242 let dir = tempfile::tempdir().unwrap();
1243 let out = dir.path().join("export.json");
1244 super::cmd_session_export(&s.uri(), "s-001", "json", Some(out.to_str().unwrap()))
1245 .await
1246 .unwrap();
1247 assert!(out.exists());
1248 let content = std::fs::read_to_string(&out).unwrap();
1249 assert!(content.contains("s-001"));
1250 }
1251
1252 #[tokio::test]
1253 async fn cmd_session_export_markdown() {
1254 let s = MockServer::start().await;
1255 mock_get(
1256 &s,
1257 "/api/sessions/s-001",
1258 serde_json::json!({
1259 "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1260 }),
1261 )
1262 .await;
1263 mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1264 "messages": [{"role": "user", "content": "Hi", "created_at": "2025-01-01T00:00:01Z"}]
1265 })).await;
1266 let dir = tempfile::tempdir().unwrap();
1267 let out = dir.path().join("export.md");
1268 super::cmd_session_export(&s.uri(), "s-001", "markdown", Some(out.to_str().unwrap()))
1269 .await
1270 .unwrap();
1271 assert!(out.exists());
1272 let content = std::fs::read_to_string(&out).unwrap();
1273 assert!(content.contains("# Session"));
1274 }
1275
1276 #[tokio::test]
1277 async fn cmd_session_export_html() {
1278 let s = MockServer::start().await;
1279 mock_get(
1280 &s,
1281 "/api/sessions/s-001",
1282 serde_json::json!({
1283 "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1284 }),
1285 )
1286 .await;
1287 mock_get(&s, "/api/sessions/s-001/messages", serde_json::json!({
1288 "messages": [
1289 {"role": "user", "content": "Hello <world> & \"friends\"", "created_at": "2025-01-01T00:00:01Z"},
1290 {"role": "assistant", "content": "Hi", "created_at": "2025-01-01T00:00:02Z"},
1291 {"role": "system", "content": "Sys", "created_at": "2025-01-01T00:00:00Z"},
1292 {"role": "tool", "content": "Tool output", "created_at": "2025-01-01T00:00:03Z"}
1293 ]
1294 })).await;
1295 let dir = tempfile::tempdir().unwrap();
1296 let out = dir.path().join("export.html");
1297 super::cmd_session_export(&s.uri(), "s-001", "html", Some(out.to_str().unwrap()))
1298 .await
1299 .unwrap();
1300 let content = std::fs::read_to_string(&out).unwrap();
1301 assert!(content.contains("<!DOCTYPE html>"));
1302 assert!(content.contains("&"));
1303 assert!(content.contains("<"));
1304 }
1305
1306 #[tokio::test]
1307 async fn cmd_session_export_to_stdout() {
1308 let s = MockServer::start().await;
1309 mock_get(
1310 &s,
1311 "/api/sessions/s-001",
1312 serde_json::json!({
1313 "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1314 }),
1315 )
1316 .await;
1317 mock_get(
1318 &s,
1319 "/api/sessions/s-001/messages",
1320 serde_json::json!({"messages": []}),
1321 )
1322 .await;
1323 super::cmd_session_export(&s.uri(), "s-001", "json", None)
1324 .await
1325 .unwrap();
1326 }
1327
1328 #[tokio::test]
1329 async fn cmd_session_export_unknown_format() {
1330 let s = MockServer::start().await;
1331 mock_get(
1332 &s,
1333 "/api/sessions/s-001",
1334 serde_json::json!({
1335 "id": "s-001", "agent_id": "roboticus", "created_at": "2025-01-01T00:00:00Z"
1336 }),
1337 )
1338 .await;
1339 mock_get(
1340 &s,
1341 "/api/sessions/s-001/messages",
1342 serde_json::json!({"messages": []}),
1343 )
1344 .await;
1345 super::cmd_session_export(&s.uri(), "s-001", "csv", None)
1346 .await
1347 .unwrap();
1348 }
1349
1350 #[tokio::test]
1351 async fn cmd_session_export_not_found() {
1352 let s = MockServer::start().await;
1353 Mock::given(method("GET"))
1354 .and(path("/api/sessions/missing"))
1355 .respond_with(ResponseTemplate::new(404))
1356 .mount(&s)
1357 .await;
1358 super::cmd_session_export(&s.uri(), "missing", "json", None)
1359 .await
1360 .unwrap();
1361 }
1362
1363 #[tokio::test]
1366 async fn cmd_circuit_status_with_providers() {
1367 let s = MockServer::start().await;
1368 mock_get(
1369 &s,
1370 "/api/breaker/status",
1371 serde_json::json!({
1372 "providers": {
1373 "ollama": {"state": "closed"},
1374 "openai": {"state": "open"}
1375 },
1376 "note": "All good"
1377 }),
1378 )
1379 .await;
1380 super::cmd_circuit_status(&s.uri(), false).await.unwrap();
1381 }
1382
1383 #[tokio::test]
1384 async fn cmd_circuit_status_empty_providers() {
1385 let s = MockServer::start().await;
1386 mock_get(
1387 &s,
1388 "/api/breaker/status",
1389 serde_json::json!({"providers": {}}),
1390 )
1391 .await;
1392 super::cmd_circuit_status(&s.uri(), false).await.unwrap();
1393 }
1394
1395 #[tokio::test]
1396 async fn cmd_circuit_status_no_providers_key() {
1397 let s = MockServer::start().await;
1398 mock_get(&s, "/api/breaker/status", serde_json::json!({})).await;
1399 super::cmd_circuit_status(&s.uri(), false).await.unwrap();
1400 }
1401
1402 #[tokio::test]
1403 async fn cmd_circuit_reset_success() {
1404 let s = MockServer::start().await;
1405 mock_get(
1406 &s,
1407 "/api/breaker/status",
1408 serde_json::json!({
1409 "providers": {
1410 "ollama": {"state": "open"},
1411 "moonshot": {"state": "open"}
1412 }
1413 }),
1414 )
1415 .await;
1416 mock_post(
1417 &s,
1418 "/api/breaker/reset/ollama",
1419 serde_json::json!({"ok": true}),
1420 )
1421 .await;
1422 mock_post(
1423 &s,
1424 "/api/breaker/reset/moonshot",
1425 serde_json::json!({"ok": true}),
1426 )
1427 .await;
1428 super::cmd_circuit_reset(&s.uri(), None).await.unwrap();
1429 }
1430
1431 #[tokio::test]
1432 async fn cmd_circuit_reset_server_error() {
1433 let s = MockServer::start().await;
1434 Mock::given(method("GET"))
1435 .and(path("/api/breaker/status"))
1436 .respond_with(ResponseTemplate::new(500))
1437 .mount(&s)
1438 .await;
1439 super::cmd_circuit_reset(&s.uri(), None).await.unwrap();
1440 }
1441
1442 #[tokio::test]
1443 async fn cmd_circuit_reset_single_provider() {
1444 let s = MockServer::start().await;
1445 mock_post(
1446 &s,
1447 "/api/breaker/reset/openai",
1448 serde_json::json!({"ok": true}),
1449 )
1450 .await;
1451 super::cmd_circuit_reset(&s.uri(), Some("openai"))
1452 .await
1453 .unwrap();
1454 }
1455
1456 #[tokio::test]
1459 async fn cmd_agents_list_with_agents() {
1460 let s = MockServer::start().await;
1461 mock_get(
1462 &s,
1463 "/api/agents",
1464 serde_json::json!({
1465 "agents": [
1466 {"id": "roboticus", "name": "Roboticus", "state": "running", "model": "qwen3:8b"},
1467 {"id": "duncan", "name": "Duncan", "state": "sleeping", "model": "gpt-4o"}
1468 ]
1469 }),
1470 )
1471 .await;
1472 super::cmd_agents_list(&s.uri(), false).await.unwrap();
1473 }
1474
1475 #[tokio::test]
1476 async fn cmd_agents_list_empty() {
1477 let s = MockServer::start().await;
1478 mock_get(&s, "/api/agents", serde_json::json!({"agents": []})).await;
1479 super::cmd_agents_list(&s.uri(), false).await.unwrap();
1480 }
1481
1482 #[tokio::test]
1485 async fn cmd_channels_status_with_channels() {
1486 let s = MockServer::start().await;
1487 Mock::given(method("GET"))
1488 .and(path("/api/channels/status"))
1489 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1490 {"name": "telegram", "connected": true, "messages_received": 100, "messages_sent": 50},
1491 {"name": "whatsapp", "connected": false, "messages_received": 0, "messages_sent": 0}
1492 ])))
1493 .mount(&s)
1494 .await;
1495 super::cmd_channels_status(&s.uri(), false).await.unwrap();
1496 }
1497
1498 #[tokio::test]
1499 async fn cmd_channels_status_empty() {
1500 let s = MockServer::start().await;
1501 Mock::given(method("GET"))
1502 .and(path("/api/channels/status"))
1503 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1504 .mount(&s)
1505 .await;
1506 super::cmd_channels_status(&s.uri(), false).await.unwrap();
1507 }
1508
1509 #[tokio::test]
1510 async fn cmd_channels_dead_letter_with_items() {
1511 let s = MockServer::start().await;
1512 Mock::given(method("GET"))
1513 .and(path("/api/channels/dead-letter"))
1514 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1515 "items": [
1516 {"id": "dl-1", "channel": "telegram", "attempts": 5, "max_attempts": 5, "last_error": "blocked"}
1517 ]
1518 })))
1519 .mount(&s)
1520 .await;
1521 super::cmd_channels_dead_letter(&s.uri(), 10, false)
1522 .await
1523 .unwrap();
1524 }
1525
1526 #[tokio::test]
1527 async fn cmd_channels_replay_ok() {
1528 let s = MockServer::start().await;
1529 Mock::given(method("POST"))
1530 .and(path("/api/channels/dead-letter/dl-1/replay"))
1531 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
1532 .mount(&s)
1533 .await;
1534 super::cmd_channels_replay(&s.uri(), "dl-1").await.unwrap();
1535 }
1536
1537 #[tokio::test]
1540 async fn cmd_plugins_list_with_plugins() {
1541 let s = MockServer::start().await;
1542 mock_get(&s, "/api/plugins", serde_json::json!({
1543 "plugins": [
1544 {"name": "weather", "version": "1.0", "status": "active", "tools": [{"name": "get_weather"}]},
1545 {"name": "empty", "version": "0.1", "status": "inactive", "tools": []}
1546 ]
1547 })).await;
1548 super::cmd_plugins_list(&s.uri(), false).await.unwrap();
1549 }
1550
1551 #[tokio::test]
1552 async fn cmd_plugins_list_empty() {
1553 let s = MockServer::start().await;
1554 mock_get(&s, "/api/plugins", serde_json::json!({"plugins": []})).await;
1555 super::cmd_plugins_list(&s.uri(), false).await.unwrap();
1556 }
1557
1558 #[tokio::test]
1559 async fn cmd_plugin_info_found() {
1560 let s = MockServer::start().await;
1561 mock_get(
1562 &s,
1563 "/api/plugins",
1564 serde_json::json!({
1565 "plugins": [
1566 {
1567 "name": "weather", "version": "1.0", "description": "Weather plugin",
1568 "enabled": true, "manifest_path": "/plugins/weather/plugin.toml",
1569 "tools": [{"name": "get_weather"}, {"name": "get_forecast"}]
1570 }
1571 ]
1572 }),
1573 )
1574 .await;
1575 super::cmd_plugin_info(&s.uri(), "weather", false)
1576 .await
1577 .unwrap();
1578 }
1579
1580 #[tokio::test]
1581 async fn cmd_plugin_info_disabled() {
1582 let s = MockServer::start().await;
1583 mock_get(
1584 &s,
1585 "/api/plugins",
1586 serde_json::json!({
1587 "plugins": [{"name": "old", "version": "0.1", "enabled": false}]
1588 }),
1589 )
1590 .await;
1591 super::cmd_plugin_info(&s.uri(), "old", false)
1592 .await
1593 .unwrap();
1594 }
1595
1596 #[tokio::test]
1597 async fn cmd_plugin_info_not_found() {
1598 let s = MockServer::start().await;
1599 mock_get(&s, "/api/plugins", serde_json::json!({"plugins": []})).await;
1600 let result = super::cmd_plugin_info(&s.uri(), "nonexistent", false).await;
1601 assert!(result.is_err());
1602 }
1603
1604 #[tokio::test]
1605 async fn cmd_plugin_toggle_enable() {
1606 let s = MockServer::start().await;
1607 Mock::given(method("PUT"))
1608 .and(path("/api/plugins/weather/toggle"))
1609 .respond_with(ResponseTemplate::new(200))
1610 .mount(&s)
1611 .await;
1612 super::cmd_plugin_toggle(&s.uri(), "weather", true)
1613 .await
1614 .unwrap();
1615 }
1616
1617 #[tokio::test]
1618 async fn cmd_plugin_toggle_disable_fails() {
1619 let s = MockServer::start().await;
1620 Mock::given(method("PUT"))
1621 .and(path("/api/plugins/weather/toggle"))
1622 .respond_with(ResponseTemplate::new(404))
1623 .mount(&s)
1624 .await;
1625 let result = super::cmd_plugin_toggle(&s.uri(), "weather", false).await;
1626 assert!(result.is_err());
1627 }
1628
1629 #[tokio::test]
1630 async fn cmd_plugin_install_missing_source() {
1631 let result = super::cmd_plugin_install("/tmp/roboticus_test_nonexistent_plugin_dir").await;
1632 assert!(result.is_err());
1633 }
1634
1635 #[tokio::test]
1636 async fn cmd_plugin_install_no_manifest() {
1637 let dir = tempfile::tempdir().unwrap();
1638 let result = super::cmd_plugin_install(dir.path().to_str().unwrap()).await;
1639 assert!(result.is_err());
1640 }
1641
1642 #[serial_test::serial]
1643 #[tokio::test]
1644 async fn cmd_plugin_install_valid() {
1645 let src_dir = tempfile::tempdir().unwrap();
1649 let home_dir = tempfile::tempdir().unwrap();
1650 let manifest = src_dir.path().join("plugin.toml");
1651 std::fs::write(&manifest, "name = \"test-plugin\"\nversion = \"0.1\"").unwrap();
1652 std::fs::write(src_dir.path().join("main.gosh"), "print(\"hi\")").unwrap();
1653
1654 let sub = src_dir.path().join("sub");
1655 std::fs::create_dir(&sub).unwrap();
1656 std::fs::write(sub.join("helper.gosh"), "// helper").unwrap();
1657
1658 let _home_guard =
1659 crate::test_support::EnvGuard::set("HOME", home_dir.path().to_str().unwrap());
1660 let _ = super::cmd_plugin_install(src_dir.path().to_str().unwrap()).await;
1661 }
1662
1663 #[serial_test::serial]
1664 #[test]
1665 fn cmd_plugin_uninstall_not_found() {
1666 let _home_guard =
1667 crate::test_support::EnvGuard::set("HOME", "/tmp/roboticus_test_uninstall_home");
1668 let result = super::cmd_plugin_uninstall("nonexistent");
1669 assert!(
1670 result.is_err(),
1671 "uninstall of nonexistent plugin should fail"
1672 );
1673 }
1674
1675 #[serial_test::serial]
1676 #[test]
1677 fn cmd_plugin_uninstall_exists() {
1678 let dir = tempfile::tempdir().unwrap();
1679 let plugins_dir = dir
1680 .path()
1681 .join(".roboticus")
1682 .join("plugins")
1683 .join("myplugin");
1684 std::fs::create_dir_all(&plugins_dir).unwrap();
1685 std::fs::write(plugins_dir.join("plugin.toml"), "name = \"myplugin\"").unwrap();
1686 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
1687 super::cmd_plugin_uninstall("myplugin").unwrap();
1688 assert!(!plugins_dir.exists());
1689 }
1690
1691 #[tokio::test]
1694 async fn cmd_models_list_full_config() {
1695 let s = MockServer::start().await;
1696 mock_get(&s, "/api/config", serde_json::json!({
1697 "models": {
1698 "primary": "qwen3:8b",
1699 "fallbacks": ["gpt-4o", "claude-3"],
1700 "routing": { "mode": "adaptive", "confidence_threshold": 0.85, "local_first": false }
1701 }
1702 })).await;
1703 super::cmd_models_list(&s.uri(), false).await.unwrap();
1704 }
1705
1706 #[tokio::test]
1707 async fn cmd_models_list_minimal_config() {
1708 let s = MockServer::start().await;
1709 mock_get(&s, "/api/config", serde_json::json!({})).await;
1710 super::cmd_models_list(&s.uri(), false).await.unwrap();
1711 }
1712
1713 #[tokio::test]
1714 async fn cmd_models_scan_no_providers() {
1715 let s = MockServer::start().await;
1716 mock_get(&s, "/api/config", serde_json::json!({"providers": {}})).await;
1717 super::cmd_models_scan(&s.uri(), None).await.unwrap();
1718 }
1719
1720 #[tokio::test]
1721 async fn cmd_models_scan_with_local_provider() {
1722 let s = MockServer::start().await;
1723 mock_get(
1724 &s,
1725 "/api/config",
1726 serde_json::json!({
1727 "providers": {
1728 "ollama": {"url": &format!("{}/ollama", s.uri())}
1729 }
1730 }),
1731 )
1732 .await;
1733 Mock::given(method("GET"))
1734 .and(path("/ollama/v1/models"))
1735 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1736 "data": [{"id": "qwen3:8b"}, {"id": "llama3:70b"}]
1737 })))
1738 .mount(&s)
1739 .await;
1740 super::cmd_models_scan(&s.uri(), None).await.unwrap();
1741 }
1742
1743 #[tokio::test]
1744 async fn cmd_models_scan_local_ollama() {
1745 let s = MockServer::start().await;
1746 let _ollama_url = s.uri().to_string().replace("http://", "http://localhost:");
1747 mock_get(
1748 &s,
1749 "/api/config",
1750 serde_json::json!({
1751 "providers": {
1752 "ollama": {"url": &s.uri()}
1753 }
1754 }),
1755 )
1756 .await;
1757 Mock::given(method("GET"))
1758 .and(path("/api/tags"))
1759 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1760 "models": [{"name": "qwen3:8b"}, {"model": "llama3"}]
1761 })))
1762 .mount(&s)
1763 .await;
1764 super::cmd_models_scan(&s.uri(), Some("ollama"))
1765 .await
1766 .unwrap();
1767 }
1768
1769 #[tokio::test]
1770 async fn cmd_models_scan_provider_filter_skips_others() {
1771 let s = MockServer::start().await;
1772 mock_get(
1773 &s,
1774 "/api/config",
1775 serde_json::json!({
1776 "providers": {
1777 "ollama": {"url": "http://localhost:11434"},
1778 "openai": {"url": "https://api.openai.com"}
1779 }
1780 }),
1781 )
1782 .await;
1783 super::cmd_models_scan(&s.uri(), Some("openai"))
1784 .await
1785 .unwrap();
1786 }
1787
1788 #[tokio::test]
1789 async fn cmd_models_scan_empty_url() {
1790 let s = MockServer::start().await;
1791 mock_get(
1792 &s,
1793 "/api/config",
1794 serde_json::json!({
1795 "providers": { "test": {"url": ""} }
1796 }),
1797 )
1798 .await;
1799 super::cmd_models_scan(&s.uri(), None).await.unwrap();
1800 }
1801
1802 #[tokio::test]
1803 async fn cmd_models_scan_error_response() {
1804 let s = MockServer::start().await;
1805 mock_get(
1806 &s,
1807 "/api/config",
1808 serde_json::json!({
1809 "providers": {
1810 "bad": {"url": &s.uri()}
1811 }
1812 }),
1813 )
1814 .await;
1815 Mock::given(method("GET"))
1816 .and(path("/v1/models"))
1817 .respond_with(ResponseTemplate::new(500))
1818 .mount(&s)
1819 .await;
1820 super::cmd_models_scan(&s.uri(), None).await.unwrap();
1821 }
1822
1823 #[tokio::test]
1824 async fn cmd_models_scan_no_models_found() {
1825 let s = MockServer::start().await;
1826 mock_get(
1827 &s,
1828 "/api/config",
1829 serde_json::json!({
1830 "providers": {
1831 "empty": {"url": &s.uri()}
1832 }
1833 }),
1834 )
1835 .await;
1836 Mock::given(method("GET"))
1837 .and(path("/v1/models"))
1838 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1839 .mount(&s)
1840 .await;
1841 super::cmd_models_scan(&s.uri(), None).await.unwrap();
1842 }
1843
1844 #[tokio::test]
1847 async fn cmd_metrics_costs_with_data() {
1848 let s = MockServer::start().await;
1849 mock_get(&s, "/api/stats/costs", serde_json::json!({
1850 "costs": [
1851 {"model": "qwen3:8b", "provider": "ollama", "tokens_in": 100, "tokens_out": 50, "cost": 0.001, "cached": false},
1852 {"model": "gpt-4o", "provider": "openai", "tokens_in": 200, "tokens_out": 100, "cost": 0.01, "cached": true}
1853 ]
1854 })).await;
1855 super::cmd_metrics(&s.uri(), "costs", None, false)
1856 .await
1857 .unwrap();
1858 }
1859
1860 #[tokio::test]
1861 async fn cmd_metrics_costs_empty() {
1862 let s = MockServer::start().await;
1863 mock_get(&s, "/api/stats/costs", serde_json::json!({"costs": []})).await;
1864 super::cmd_metrics(&s.uri(), "costs", None, false)
1865 .await
1866 .unwrap();
1867 }
1868
1869 #[tokio::test]
1870 async fn cmd_metrics_transactions_with_data() {
1871 let s = MockServer::start().await;
1872 Mock::given(method("GET"))
1873 .and(path("/api/stats/transactions"))
1874 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1875 "transactions": [
1876 {"id": "tx-001", "tx_type": "inference", "amount": 0.01, "currency": "USD",
1877 "counterparty": "openai", "created_at": "2025-01-01T12:00:00.000Z"},
1878 {"id": "tx-002", "tx_type": "transfer", "amount": 5.00, "currency": "USDC",
1879 "counterparty": "user", "created_at": "2025-01-01T13:00:00Z"}
1880 ]
1881 })))
1882 .mount(&s)
1883 .await;
1884 super::cmd_metrics(&s.uri(), "transactions", Some(48), false)
1885 .await
1886 .unwrap();
1887 }
1888
1889 #[tokio::test]
1890 async fn cmd_metrics_transactions_empty() {
1891 let s = MockServer::start().await;
1892 Mock::given(method("GET"))
1893 .and(path("/api/stats/transactions"))
1894 .respond_with(
1895 ResponseTemplate::new(200).set_body_json(serde_json::json!({"transactions": []})),
1896 )
1897 .mount(&s)
1898 .await;
1899 super::cmd_metrics(&s.uri(), "transactions", None, false)
1900 .await
1901 .unwrap();
1902 }
1903
1904 #[tokio::test]
1905 async fn cmd_metrics_cache_stats() {
1906 let s = MockServer::start().await;
1907 mock_get(
1908 &s,
1909 "/api/stats/cache",
1910 serde_json::json!({
1911 "hits": 42, "misses": 8, "entries": 100, "hit_rate": 84.0
1912 }),
1913 )
1914 .await;
1915 super::cmd_metrics(&s.uri(), "cache", None, false)
1916 .await
1917 .unwrap();
1918 }
1919
1920 #[tokio::test]
1921 async fn cmd_metrics_unknown_kind() {
1922 let s = MockServer::start().await;
1923 let result = super::cmd_metrics(&s.uri(), "bogus", None, false).await;
1924 assert!(result.is_err());
1925 }
1926
1927 #[test]
1930 fn cmd_completion_bash() {
1931 super::cmd_completion("bash").unwrap();
1932 }
1933
1934 #[test]
1935 fn cmd_completion_zsh() {
1936 super::cmd_completion("zsh").unwrap();
1937 }
1938
1939 #[test]
1940 fn cmd_completion_fish() {
1941 super::cmd_completion("fish").unwrap();
1942 }
1943
1944 #[test]
1945 fn cmd_completion_unknown() {
1946 super::cmd_completion("powershell").unwrap();
1947 }
1948
1949 #[tokio::test]
1952 async fn cmd_logs_static_with_entries() {
1953 let s = MockServer::start().await;
1954 Mock::given(method("GET"))
1955 .and(path("/api/logs"))
1956 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1957 "entries": [
1958 {"timestamp": "2025-01-01T00:00:00Z", "level": "INFO", "message": "Started", "target": "roboticus"},
1959 {"timestamp": "2025-01-01T00:00:01Z", "level": "WARN", "message": "Low memory", "target": "system"},
1960 {"timestamp": "2025-01-01T00:00:02Z", "level": "ERROR", "message": "Failed", "target": "api"},
1961 {"timestamp": "2025-01-01T00:00:03Z", "level": "DEBUG", "message": "Trace", "target": "db"},
1962 {"timestamp": "2025-01-01T00:00:04Z", "level": "TRACE", "message": "Deep", "target": "core"}
1963 ]
1964 })))
1965 .mount(&s)
1966 .await;
1967 super::cmd_logs(&s.uri(), 50, false, "info", false)
1968 .await
1969 .unwrap();
1970 }
1971
1972 #[tokio::test]
1973 async fn cmd_logs_static_empty() {
1974 let s = MockServer::start().await;
1975 Mock::given(method("GET"))
1976 .and(path("/api/logs"))
1977 .respond_with(
1978 ResponseTemplate::new(200).set_body_json(serde_json::json!({"entries": []})),
1979 )
1980 .mount(&s)
1981 .await;
1982 super::cmd_logs(&s.uri(), 10, false, "info", false)
1983 .await
1984 .unwrap();
1985 }
1986
1987 #[tokio::test]
1988 async fn cmd_logs_static_no_entries_key() {
1989 let s = MockServer::start().await;
1990 Mock::given(method("GET"))
1991 .and(path("/api/logs"))
1992 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1993 .mount(&s)
1994 .await;
1995 super::cmd_logs(&s.uri(), 10, false, "info", false)
1996 .await
1997 .unwrap();
1998 }
1999
2000 #[tokio::test]
2001 async fn cmd_logs_server_error_falls_back() {
2002 let s = MockServer::start().await;
2003 Mock::given(method("GET"))
2004 .and(path("/api/logs"))
2005 .respond_with(ResponseTemplate::new(500))
2006 .mount(&s)
2007 .await;
2008 super::cmd_logs(&s.uri(), 10, false, "info", false)
2009 .await
2010 .unwrap();
2011 }
2012
2013 #[test]
2016 fn cmd_security_audit_missing_config() {
2017 super::cmd_security_audit("/tmp/roboticus_test_nonexistent_config.toml", false).unwrap();
2018 }
2019
2020 #[test]
2021 fn cmd_security_audit_clean_config() {
2022 let dir = tempfile::tempdir().unwrap();
2023 let config = dir.path().join("roboticus.toml");
2024 std::fs::write(&config, "[server]\nbind = \"localhost\"\nport = 18789\n").unwrap();
2025 #[cfg(unix)]
2026 {
2027 use std::os::unix::fs::PermissionsExt;
2028 std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).unwrap();
2029 }
2030 super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2031 }
2032
2033 #[test]
2034 fn cmd_security_audit_plaintext_keys() {
2035 let dir = tempfile::tempdir().unwrap();
2036 let config = dir.path().join("roboticus.toml");
2037 std::fs::write(&config, "[providers.openai]\napi_key = \"sk-secret123\"\n").unwrap();
2038 #[cfg(unix)]
2039 {
2040 use std::os::unix::fs::PermissionsExt;
2041 std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).unwrap();
2042 }
2043 super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2044 }
2045
2046 #[test]
2047 fn cmd_security_audit_env_var_keys() {
2048 let dir = tempfile::tempdir().unwrap();
2049 let config = dir.path().join("roboticus.toml");
2050 std::fs::write(&config, "[providers.openai]\napi_key = \"${OPENAI_KEY}\"\n").unwrap();
2051 super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2052 }
2053
2054 #[test]
2055 fn cmd_security_audit_wildcard_cors() {
2056 let dir = tempfile::tempdir().unwrap();
2057 let config = dir.path().join("roboticus.toml");
2058 std::fs::write(
2059 &config,
2060 "[server]\nbind = \"0.0.0.0\"\n\n[cors]\norigins = \"*\"\n",
2061 )
2062 .unwrap();
2063 super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2064 }
2065
2066 #[cfg(unix)]
2067 #[test]
2068 fn cmd_security_audit_loose_config_permissions() {
2069 let dir = tempfile::tempdir().unwrap();
2070 let config = dir.path().join("roboticus.toml");
2071 std::fs::write(&config, "[server]\nport = 18789\n").unwrap();
2072 use std::os::unix::fs::PermissionsExt;
2073 std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o644)).unwrap();
2074 super::cmd_security_audit(config.to_str().unwrap(), false).unwrap();
2075 }
2076
2077 #[serial_test::serial]
2080 #[test]
2081 fn cmd_reset_yes_no_db() {
2082 let dir = tempfile::tempdir().unwrap();
2083 let roboticus_dir = dir.path().join(".roboticus");
2084 std::fs::create_dir_all(&roboticus_dir).unwrap();
2085 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2086 super::cmd_reset(true).unwrap();
2087 }
2088
2089 #[serial_test::serial]
2090 #[test]
2091 fn cmd_reset_yes_with_db_and_config() {
2092 let dir = tempfile::tempdir().unwrap();
2093 let roboticus_dir = dir.path().join(".roboticus");
2094 std::fs::create_dir_all(&roboticus_dir).unwrap();
2095 std::fs::write(roboticus_dir.join("state.db"), "fake db").unwrap();
2096 std::fs::write(roboticus_dir.join("state.db-wal"), "wal").unwrap();
2097 std::fs::write(roboticus_dir.join("state.db-shm"), "shm").unwrap();
2098 std::fs::write(roboticus_dir.join("roboticus.toml"), "[server]").unwrap();
2099 std::fs::create_dir_all(roboticus_dir.join("logs")).unwrap();
2100 std::fs::write(roboticus_dir.join("wallet.json"), "{}").unwrap();
2101 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2102 super::cmd_reset(true).unwrap();
2103 assert!(!roboticus_dir.join("state.db").exists());
2104 assert!(!roboticus_dir.join("roboticus.toml").exists());
2105 assert!(!roboticus_dir.join("logs").exists());
2106 assert!(roboticus_dir.join("wallet.json").exists());
2107 }
2108
2109 #[serial_test::serial]
2112 #[tokio::test]
2113 async fn cmd_mechanic_gateway_up() {
2114 let s = MockServer::start().await;
2115 mock_get(&s, "/api/health", serde_json::json!({"status": "ok"})).await;
2116 mock_get(&s, "/api/config", serde_json::json!({"models": {}})).await;
2117 mock_get(
2118 &s,
2119 "/api/skills",
2120 serde_json::json!({"skills": [{"id": "s1"}]}),
2121 )
2122 .await;
2123 mock_get(
2124 &s,
2125 "/api/wallet/balance",
2126 serde_json::json!({"balance": "1.00"}),
2127 )
2128 .await;
2129 Mock::given(method("GET"))
2130 .and(path("/api/channels/status"))
2131 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2132 {"connected": true}, {"connected": false}
2133 ])))
2134 .mount(&s)
2135 .await;
2136 let dir = tempfile::tempdir().unwrap();
2137 let roboticus_dir = dir.path().join(".roboticus");
2138 for sub in &["workspace", "skills", "plugins", "logs"] {
2139 std::fs::create_dir_all(roboticus_dir.join(sub)).unwrap();
2140 }
2141 std::fs::write(roboticus_dir.join("roboticus.toml"), "[server]").unwrap();
2142 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2143 let _ = super::cmd_mechanic(&s.uri(), false, false, &[]).await;
2144 }
2145
2146 #[serial_test::serial]
2147 #[tokio::test]
2148 async fn cmd_mechanic_gateway_down() {
2149 let s = MockServer::start().await;
2150 Mock::given(method("GET"))
2151 .and(path("/api/health"))
2152 .respond_with(ResponseTemplate::new(503))
2153 .mount(&s)
2154 .await;
2155 let dir = tempfile::tempdir().unwrap();
2156 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2157 let _ = super::cmd_mechanic(&s.uri(), false, false, &[]).await;
2158 }
2159
2160 #[serial_test::serial]
2161 #[tokio::test]
2162 #[ignore = "sets HOME globally, racy with parallel tests — run with --ignored"]
2163 async fn cmd_mechanic_repair_creates_dirs() {
2164 let s = MockServer::start().await;
2165 Mock::given(method("GET"))
2166 .and(path("/api/health"))
2167 .respond_with(
2168 ResponseTemplate::new(200).set_body_json(serde_json::json!({"status": "ok"})),
2169 )
2170 .mount(&s)
2171 .await;
2172 mock_get(&s, "/api/config", serde_json::json!({})).await;
2173 mock_get(&s, "/api/skills", serde_json::json!({"skills": []})).await;
2174 mock_get(&s, "/api/wallet/balance", serde_json::json!({})).await;
2175 Mock::given(method("GET"))
2176 .and(path("/api/channels/status"))
2177 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
2178 .mount(&s)
2179 .await;
2180 let dir = tempfile::tempdir().unwrap();
2181 let _home_guard = crate::test_support::EnvGuard::set("HOME", dir.path().to_str().unwrap());
2182 let _ = super::cmd_mechanic(&s.uri(), true, false, &[]).await;
2183 assert!(dir.path().join(".roboticus").join("workspace").exists());
2184 }
2185}