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