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