1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use log::{error, info};
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct ContainerInfo {
16 #[serde(rename = "ID")]
17 pub id: String,
18 #[serde(rename = "Names")]
19 pub names: String,
20 #[serde(rename = "Image")]
21 pub image: String,
22 #[serde(rename = "State")]
23 pub state: String,
24 #[serde(rename = "Status")]
25 pub status: String,
26 #[serde(rename = "Ports")]
27 pub ports: String,
28}
29
30pub fn parse_container_ps(output: &str) -> Vec<ContainerInfo> {
33 output
34 .lines()
35 .filter_map(|line| {
36 let trimmed = line.trim();
37 if trimmed.is_empty() {
38 return None;
39 }
40 serde_json::from_str(trimmed).ok()
41 })
42 .collect()
43}
44
45#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
51pub enum ContainerRuntime {
52 Docker,
53 Podman,
54}
55
56impl ContainerRuntime {
57 pub fn as_str(&self) -> &'static str {
59 match self {
60 ContainerRuntime::Docker => "docker",
61 ContainerRuntime::Podman => "podman",
62 }
63 }
64}
65
66#[allow(dead_code)]
71pub fn parse_runtime(output: &str) -> Option<ContainerRuntime> {
72 let last = output
73 .lines()
74 .rev()
75 .map(|l| l.trim())
76 .find(|l| !l.is_empty())?;
77 match last {
78 "docker" => Some(ContainerRuntime::Docker),
79 "podman" => Some(ContainerRuntime::Podman),
80 _ => None,
81 }
82}
83
84#[derive(Copy, Clone, Debug, PartialEq)]
90pub enum ContainerAction {
91 Start,
92 Stop,
93 Restart,
94}
95
96impl ContainerAction {
97 pub fn as_str(&self) -> &'static str {
99 match self {
100 ContainerAction::Start => "start",
101 ContainerAction::Stop => "stop",
102 ContainerAction::Restart => "restart",
103 }
104 }
105}
106
107pub fn container_action_command(
109 runtime: ContainerRuntime,
110 action: ContainerAction,
111 container_id: &str,
112) -> String {
113 format!("{} {} {}", runtime.as_str(), action.as_str(), container_id)
114}
115
116pub fn validate_container_id(id: &str) -> Result<(), String> {
124 if id.is_empty() {
125 return Err("Container ID must not be empty.".to_string());
126 }
127 for c in id.chars() {
128 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.' {
129 return Err(format!("Container ID contains invalid character: '{c}'"));
130 }
131 }
132 Ok(())
133}
134
135pub fn container_list_command(runtime: Option<ContainerRuntime>) -> String {
144 match runtime {
145 Some(ContainerRuntime::Docker) => "docker ps -a --format '{{json .}}'".to_string(),
146 Some(ContainerRuntime::Podman) => "podman ps -a --format '{{json .}}'".to_string(),
147 None => concat!(
148 "if command -v docker >/dev/null 2>&1; then ",
149 "echo '##purple:docker##' && docker ps -a --format '{{json .}}'; ",
150 "elif command -v podman >/dev/null 2>&1; then ",
151 "echo '##purple:podman##' && podman ps -a --format '{{json .}}'; ",
152 "else echo '##purple:none##'; fi"
153 )
154 .to_string(),
155 }
156}
157
158pub fn parse_container_output(
164 output: &str,
165 caller_runtime: Option<ContainerRuntime>,
166) -> Result<(ContainerRuntime, Vec<ContainerInfo>), String> {
167 if let Some(sentinel_line) = output.lines().find(|l| l.trim().starts_with("##purple:")) {
168 let sentinel = sentinel_line.trim();
169 if sentinel == "##purple:none##" {
170 return Err("No container runtime found. Install Docker or Podman.".to_string());
171 }
172 let runtime = if sentinel == "##purple:docker##" {
173 ContainerRuntime::Docker
174 } else if sentinel == "##purple:podman##" {
175 ContainerRuntime::Podman
176 } else {
177 return Err(format!("Unknown sentinel: {sentinel}"));
178 };
179 let containers: Vec<ContainerInfo> = output
180 .lines()
181 .filter(|l| !l.trim().starts_with("##purple:"))
182 .filter_map(|line| {
183 let t = line.trim();
184 if t.is_empty() {
185 return None;
186 }
187 serde_json::from_str(t).ok()
188 })
189 .collect();
190 return Ok((runtime, containers));
191 }
192
193 match caller_runtime {
194 Some(rt) => Ok((rt, parse_container_ps(output))),
195 None => Err("No sentinel found and no runtime provided.".to_string()),
196 }
197}
198
199#[derive(Debug)]
206pub struct ContainerError {
207 pub runtime: Option<ContainerRuntime>,
208 pub message: String,
209}
210
211impl std::fmt::Display for ContainerError {
212 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213 write!(f, "{}", self.message)
214 }
215}
216
217fn friendly_container_error(stderr: &str, code: Option<i32>) -> String {
219 let lower = stderr.to_lowercase();
220 if lower.contains("command not found") {
221 "Docker or Podman not found on remote host.".to_string()
222 } else if lower.contains("permission denied") || lower.contains("got permission denied") {
223 "Permission denied. Is your user in the docker group?".to_string()
224 } else if lower.contains("cannot connect to the docker daemon")
225 || lower.contains("cannot connect to podman")
226 {
227 "Container daemon is not running.".to_string()
228 } else if lower.contains("connection refused") {
229 "Connection refused.".to_string()
230 } else if lower.contains("no route to host") || lower.contains("network is unreachable") {
231 "Host unreachable.".to_string()
232 } else {
233 format!("Command failed with code {}.", code.unwrap_or(1))
234 }
235}
236
237#[allow(clippy::too_many_arguments)]
240pub fn fetch_containers(
241 alias: &str,
242 config_path: &Path,
243 askpass: Option<&str>,
244 bw_session: Option<&str>,
245 has_tunnel: bool,
246 cached_runtime: Option<ContainerRuntime>,
247) -> Result<(ContainerRuntime, Vec<ContainerInfo>), ContainerError> {
248 let command = container_list_command(cached_runtime);
249 let result = crate::snippet::run_snippet(
250 alias,
251 config_path,
252 &command,
253 askpass,
254 bw_session,
255 true,
256 has_tunnel,
257 );
258 match result {
259 Ok(r) if r.status.success() => {
260 parse_container_output(&r.stdout, cached_runtime).map_err(|e| {
261 error!("[external] Container list parse failed: alias={alias}: {e}");
262 ContainerError {
263 runtime: cached_runtime,
264 message: e,
265 }
266 })
267 }
268 Ok(r) => {
269 let stderr = r.stderr.trim().to_string();
270 let msg = friendly_container_error(&stderr, r.status.code());
271 error!("[external] Container fetch failed: alias={alias}: {msg}");
272 Err(ContainerError {
273 runtime: cached_runtime,
274 message: msg,
275 })
276 }
277 Err(e) => {
278 error!("[external] Container fetch failed: alias={alias}: {e}");
279 Err(ContainerError {
280 runtime: cached_runtime,
281 message: e.to_string(),
282 })
283 }
284 }
285}
286
287#[allow(clippy::too_many_arguments)]
290pub fn spawn_container_listing<F>(
291 alias: String,
292 config_path: PathBuf,
293 askpass: Option<String>,
294 bw_session: Option<String>,
295 has_tunnel: bool,
296 cached_runtime: Option<ContainerRuntime>,
297 send: F,
298) where
299 F: FnOnce(String, Result<(ContainerRuntime, Vec<ContainerInfo>), ContainerError>)
300 + Send
301 + 'static,
302{
303 std::thread::spawn(move || {
304 let result = fetch_containers(
305 &alias,
306 &config_path,
307 askpass.as_deref(),
308 bw_session.as_deref(),
309 has_tunnel,
310 cached_runtime,
311 );
312 send(alias, result);
313 });
314}
315
316#[allow(clippy::too_many_arguments)]
319pub fn spawn_container_action<F>(
320 alias: String,
321 config_path: PathBuf,
322 runtime: ContainerRuntime,
323 action: ContainerAction,
324 container_id: String,
325 askpass: Option<String>,
326 bw_session: Option<String>,
327 has_tunnel: bool,
328 send: F,
329) where
330 F: FnOnce(String, ContainerAction, Result<(), String>) + Send + 'static,
331{
332 std::thread::spawn(move || {
333 if let Err(e) = validate_container_id(&container_id) {
334 send(alias, action, Err(e));
335 return;
336 }
337 info!(
338 "Container action: {} container={container_id} alias={alias}",
339 action.as_str()
340 );
341 let command = container_action_command(runtime, action, &container_id);
342 let result = crate::snippet::run_snippet(
343 &alias,
344 &config_path,
345 &command,
346 askpass.as_deref(),
347 bw_session.as_deref(),
348 true,
349 has_tunnel,
350 );
351 match result {
352 Ok(r) if r.status.success() => send(alias, action, Ok(())),
353 Ok(r) => {
354 let err = friendly_container_error(r.stderr.trim(), r.status.code());
355 error!(
356 "[external] Container {} failed: alias={alias} container={container_id}: {err}",
357 action.as_str()
358 );
359 send(alias, action, Err(err));
360 }
361 Err(e) => {
362 error!(
363 "[external] Container {} failed: alias={alias} container={container_id}: {e}",
364 action.as_str()
365 );
366 send(alias, action, Err(e.to_string()));
367 }
368 }
369 });
370}
371
372#[derive(Debug, Clone)]
378pub struct ContainerCacheEntry {
379 pub timestamp: u64,
380 pub runtime: ContainerRuntime,
381 pub containers: Vec<ContainerInfo>,
382}
383
384#[derive(Serialize, Deserialize)]
386struct CacheLine {
387 alias: String,
388 timestamp: u64,
389 runtime: ContainerRuntime,
390 containers: Vec<ContainerInfo>,
391}
392
393pub fn load_container_cache() -> HashMap<String, ContainerCacheEntry> {
396 let mut map = HashMap::new();
397 let Some(home) = dirs::home_dir() else {
398 return map;
399 };
400 let path = home.join(".purple").join("container_cache.jsonl");
401 let Ok(content) = std::fs::read_to_string(&path) else {
402 return map;
403 };
404 for line in content.lines() {
405 let trimmed = line.trim();
406 if trimmed.is_empty() {
407 continue;
408 }
409 if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
410 map.insert(
411 entry.alias,
412 ContainerCacheEntry {
413 timestamp: entry.timestamp,
414 runtime: entry.runtime,
415 containers: entry.containers,
416 },
417 );
418 }
419 }
420 map
421}
422
423pub fn parse_container_cache_content(content: &str) -> HashMap<String, ContainerCacheEntry> {
425 let mut map = HashMap::new();
426 for line in content.lines() {
427 let trimmed = line.trim();
428 if trimmed.is_empty() {
429 continue;
430 }
431 if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
432 map.insert(
433 entry.alias,
434 ContainerCacheEntry {
435 timestamp: entry.timestamp,
436 runtime: entry.runtime,
437 containers: entry.containers,
438 },
439 );
440 }
441 }
442 map
443}
444
445pub fn save_container_cache(cache: &HashMap<String, ContainerCacheEntry>) {
447 if crate::demo_flag::is_demo() {
448 return;
449 }
450 let Some(home) = dirs::home_dir() else {
451 return;
452 };
453 let path = home.join(".purple").join("container_cache.jsonl");
454 let mut lines = Vec::with_capacity(cache.len());
455 for (alias, entry) in cache {
456 let line = CacheLine {
457 alias: alias.clone(),
458 timestamp: entry.timestamp,
459 runtime: entry.runtime,
460 containers: entry.containers.clone(),
461 };
462 if let Ok(s) = serde_json::to_string(&line) {
463 lines.push(s);
464 }
465 }
466 let content = lines.join("\n");
467 let _ = crate::fs_util::atomic_write(&path, content.as_bytes());
468}
469
470pub fn truncate_str(s: &str, max: usize) -> String {
476 let count = s.chars().count();
477 if count <= max {
478 s.to_string()
479 } else {
480 let cut = max.saturating_sub(2);
481 let end = s.char_indices().nth(cut).map(|(i, _)| i).unwrap_or(s.len());
482 format!("{}..", &s[..end])
483 }
484}
485
486pub fn format_relative_time(timestamp: u64) -> String {
492 let now = SystemTime::now()
493 .duration_since(UNIX_EPOCH)
494 .unwrap_or_default()
495 .as_secs();
496 let diff = now.saturating_sub(timestamp);
497 if diff < 60 {
498 "just now".to_string()
499 } else if diff < 3600 {
500 format!("{}m ago", diff / 60)
501 } else if diff < 86400 {
502 format!("{}h ago", diff / 3600)
503 } else {
504 format!("{}d ago", diff / 86400)
505 }
506}
507
508#[cfg(test)]
513mod tests {
514 use super::*;
515
516 fn make_json(
517 id: &str,
518 names: &str,
519 image: &str,
520 state: &str,
521 status: &str,
522 ports: &str,
523 ) -> String {
524 serde_json::json!({
525 "ID": id,
526 "Names": names,
527 "Image": image,
528 "State": state,
529 "Status": status,
530 "Ports": ports,
531 })
532 .to_string()
533 }
534
535 #[test]
538 fn parse_ps_empty() {
539 assert!(parse_container_ps("").is_empty());
540 assert!(parse_container_ps(" \n \n").is_empty());
541 }
542
543 #[test]
544 fn parse_ps_single() {
545 let line = make_json("abc", "web", "nginx:latest", "running", "Up 2h", "80/tcp");
546 let r = parse_container_ps(&line);
547 assert_eq!(r.len(), 1);
548 assert_eq!(r[0].id, "abc");
549 assert_eq!(r[0].names, "web");
550 assert_eq!(r[0].image, "nginx:latest");
551 assert_eq!(r[0].state, "running");
552 }
553
554 #[test]
555 fn parse_ps_multiple() {
556 let lines = [
557 make_json("a", "web", "nginx", "running", "Up", "80/tcp"),
558 make_json("b", "db", "postgres", "exited", "Exited (0)", ""),
559 ];
560 let r = parse_container_ps(&lines.join("\n"));
561 assert_eq!(r.len(), 2);
562 }
563
564 #[test]
565 fn parse_ps_invalid_lines_ignored() {
566 let valid = make_json("x", "c", "i", "running", "Up", "");
567 let input = format!("garbage\n{valid}\nalso bad");
568 assert_eq!(parse_container_ps(&input).len(), 1);
569 }
570
571 #[test]
572 fn parse_ps_all_docker_states() {
573 for state in [
574 "created",
575 "restarting",
576 "running",
577 "removing",
578 "paused",
579 "exited",
580 "dead",
581 ] {
582 let line = make_json("id", "c", "img", state, "s", "");
583 let r = parse_container_ps(&line);
584 assert_eq!(r[0].state, state, "failed for {state}");
585 }
586 }
587
588 #[test]
589 fn parse_ps_compose_names() {
590 let line = make_json("a", "myproject-redis-1", "redis:7", "running", "Up", "");
591 assert_eq!(parse_container_ps(&line)[0].names, "myproject-redis-1");
592 }
593
594 #[test]
595 fn parse_ps_sha256_image() {
596 let line = make_json("a", "app", "sha256:abcdef123456", "running", "Up", "");
597 assert!(parse_container_ps(&line)[0].image.starts_with("sha256:"));
598 }
599
600 #[test]
601 fn parse_ps_long_ports() {
602 let ports = "0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, :::80->80/tcp";
603 let line = make_json("a", "proxy", "nginx", "running", "Up", ports);
604 assert_eq!(parse_container_ps(&line)[0].ports, ports);
605 }
606
607 #[test]
610 fn runtime_docker() {
611 assert_eq!(parse_runtime("docker"), Some(ContainerRuntime::Docker));
612 }
613
614 #[test]
615 fn runtime_podman() {
616 assert_eq!(parse_runtime("podman"), Some(ContainerRuntime::Podman));
617 }
618
619 #[test]
620 fn runtime_none() {
621 assert_eq!(parse_runtime(""), None);
622 assert_eq!(parse_runtime(" "), None);
623 assert_eq!(parse_runtime("unknown"), None);
624 assert_eq!(parse_runtime("Docker"), None); }
626
627 #[test]
628 fn runtime_motd_prepended() {
629 let input = "Welcome to Ubuntu 22.04\nSystem info\ndocker";
630 assert_eq!(parse_runtime(input), Some(ContainerRuntime::Docker));
631 }
632
633 #[test]
634 fn runtime_trailing_whitespace() {
635 assert_eq!(parse_runtime("docker "), Some(ContainerRuntime::Docker));
636 assert_eq!(parse_runtime("podman\t"), Some(ContainerRuntime::Podman));
637 }
638
639 #[test]
640 fn runtime_motd_after_output() {
641 let input = "docker\nSystem update available.";
642 assert_eq!(parse_runtime(input), None);
644 }
645
646 #[test]
649 fn action_command_all_combinations() {
650 let cases = [
651 (
652 ContainerRuntime::Docker,
653 ContainerAction::Start,
654 "docker start c1",
655 ),
656 (
657 ContainerRuntime::Docker,
658 ContainerAction::Stop,
659 "docker stop c1",
660 ),
661 (
662 ContainerRuntime::Docker,
663 ContainerAction::Restart,
664 "docker restart c1",
665 ),
666 (
667 ContainerRuntime::Podman,
668 ContainerAction::Start,
669 "podman start c1",
670 ),
671 (
672 ContainerRuntime::Podman,
673 ContainerAction::Stop,
674 "podman stop c1",
675 ),
676 (
677 ContainerRuntime::Podman,
678 ContainerAction::Restart,
679 "podman restart c1",
680 ),
681 ];
682 for (rt, action, expected) in cases {
683 assert_eq!(container_action_command(rt, action, "c1"), expected);
684 }
685 }
686
687 #[test]
688 fn action_as_str() {
689 assert_eq!(ContainerAction::Start.as_str(), "start");
690 assert_eq!(ContainerAction::Stop.as_str(), "stop");
691 assert_eq!(ContainerAction::Restart.as_str(), "restart");
692 }
693
694 #[test]
695 fn runtime_as_str() {
696 assert_eq!(ContainerRuntime::Docker.as_str(), "docker");
697 assert_eq!(ContainerRuntime::Podman.as_str(), "podman");
698 }
699
700 #[test]
703 fn id_valid_hex() {
704 assert!(validate_container_id("a1b2c3d4e5f6").is_ok());
705 }
706
707 #[test]
708 fn id_valid_names() {
709 assert!(validate_container_id("myapp").is_ok());
710 assert!(validate_container_id("my-app").is_ok());
711 assert!(validate_container_id("my_app").is_ok());
712 assert!(validate_container_id("my.app").is_ok());
713 assert!(validate_container_id("myproject-web-1").is_ok());
714 }
715
716 #[test]
717 fn id_empty() {
718 assert!(validate_container_id("").is_err());
719 }
720
721 #[test]
722 fn id_space() {
723 assert!(validate_container_id("my app").is_err());
724 }
725
726 #[test]
727 fn id_newline() {
728 assert!(validate_container_id("app\n").is_err());
729 }
730
731 #[test]
732 fn id_injection_semicolon() {
733 assert!(validate_container_id("app;rm -rf /").is_err());
734 }
735
736 #[test]
737 fn id_injection_pipe() {
738 assert!(validate_container_id("app|cat /etc/passwd").is_err());
739 }
740
741 #[test]
742 fn id_injection_dollar() {
743 assert!(validate_container_id("app$HOME").is_err());
744 }
745
746 #[test]
747 fn id_injection_backtick() {
748 assert!(validate_container_id("app`whoami`").is_err());
749 }
750
751 #[test]
752 fn id_unicode_rejected() {
753 assert!(validate_container_id("app\u{00e9}").is_err());
754 assert!(validate_container_id("\u{0430}pp").is_err()); }
756
757 #[test]
758 fn id_colon_rejected() {
759 assert!(validate_container_id("app:latest").is_err());
760 }
761
762 #[test]
765 fn list_cmd_docker() {
766 assert_eq!(
767 container_list_command(Some(ContainerRuntime::Docker)),
768 "docker ps -a --format '{{json .}}'"
769 );
770 }
771
772 #[test]
773 fn list_cmd_podman() {
774 assert_eq!(
775 container_list_command(Some(ContainerRuntime::Podman)),
776 "podman ps -a --format '{{json .}}'"
777 );
778 }
779
780 #[test]
781 fn list_cmd_none_has_sentinels() {
782 let cmd = container_list_command(None);
783 assert!(cmd.contains("##purple:docker##"));
784 assert!(cmd.contains("##purple:podman##"));
785 assert!(cmd.contains("##purple:none##"));
786 }
787
788 #[test]
789 fn list_cmd_none_docker_first() {
790 let cmd = container_list_command(None);
791 let d = cmd.find("##purple:docker##").unwrap();
792 let p = cmd.find("##purple:podman##").unwrap();
793 assert!(d < p);
794 }
795
796 #[test]
799 fn output_docker_sentinel() {
800 let c = make_json("abc", "web", "nginx", "running", "Up", "80/tcp");
801 let out = format!("##purple:docker##\n{c}");
802 let (rt, cs) = parse_container_output(&out, None).unwrap();
803 assert_eq!(rt, ContainerRuntime::Docker);
804 assert_eq!(cs.len(), 1);
805 }
806
807 #[test]
808 fn output_podman_sentinel() {
809 let c = make_json("xyz", "db", "pg", "exited", "Exited", "");
810 let out = format!("##purple:podman##\n{c}");
811 let (rt, _) = parse_container_output(&out, None).unwrap();
812 assert_eq!(rt, ContainerRuntime::Podman);
813 }
814
815 #[test]
816 fn output_none_sentinel() {
817 let r = parse_container_output("##purple:none##", None);
818 assert!(r.is_err());
819 assert!(r.unwrap_err().contains("No container runtime"));
820 }
821
822 #[test]
823 fn output_no_sentinel_with_caller() {
824 let c = make_json("a", "app", "img", "running", "Up", "");
825 let (rt, cs) = parse_container_output(&c, Some(ContainerRuntime::Docker)).unwrap();
826 assert_eq!(rt, ContainerRuntime::Docker);
827 assert_eq!(cs.len(), 1);
828 }
829
830 #[test]
831 fn output_no_sentinel_no_caller() {
832 let c = make_json("a", "app", "img", "running", "Up", "");
833 assert!(parse_container_output(&c, None).is_err());
834 }
835
836 #[test]
837 fn output_motd_before_sentinel() {
838 let c = make_json("a", "app", "img", "running", "Up", "");
839 let out = format!("Welcome to server\nInfo line\n##purple:docker##\n{c}");
840 let (rt, cs) = parse_container_output(&out, None).unwrap();
841 assert_eq!(rt, ContainerRuntime::Docker);
842 assert_eq!(cs.len(), 1);
843 }
844
845 #[test]
846 fn output_empty_container_list() {
847 let (rt, cs) = parse_container_output("##purple:docker##\n", None).unwrap();
848 assert_eq!(rt, ContainerRuntime::Docker);
849 assert!(cs.is_empty());
850 }
851
852 #[test]
853 fn output_multiple_containers() {
854 let c1 = make_json("a", "web", "nginx", "running", "Up", "80/tcp");
855 let c2 = make_json("b", "db", "pg", "exited", "Exited", "");
856 let c3 = make_json("c", "cache", "redis", "running", "Up", "6379/tcp");
857 let out = format!("##purple:podman##\n{c1}\n{c2}\n{c3}");
858 let (_, cs) = parse_container_output(&out, None).unwrap();
859 assert_eq!(cs.len(), 3);
860 }
861
862 #[test]
865 fn friendly_error_command_not_found() {
866 let msg = friendly_container_error("bash: docker: command not found", Some(127));
867 assert_eq!(msg, "Docker or Podman not found on remote host.");
868 }
869
870 #[test]
871 fn friendly_error_permission_denied() {
872 let msg = friendly_container_error(
873 "Got permission denied while trying to connect to the Docker daemon socket",
874 Some(1),
875 );
876 assert_eq!(msg, "Permission denied. Is your user in the docker group?");
877 }
878
879 #[test]
880 fn friendly_error_daemon_not_running() {
881 let msg = friendly_container_error(
882 "Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
883 Some(1),
884 );
885 assert_eq!(msg, "Container daemon is not running.");
886 }
887
888 #[test]
889 fn friendly_error_connection_refused() {
890 let msg = friendly_container_error("ssh: connect to host: Connection refused", Some(255));
891 assert_eq!(msg, "Connection refused.");
892 }
893
894 #[test]
895 fn friendly_error_empty_stderr() {
896 let msg = friendly_container_error("", Some(1));
897 assert_eq!(msg, "Command failed with code 1.");
898 }
899
900 #[test]
901 fn friendly_error_unknown_stderr_uses_generic_message() {
902 let msg = friendly_container_error("some unknown error", Some(1));
903 assert_eq!(msg, "Command failed with code 1.");
904 }
905
906 #[test]
909 fn cache_round_trip() {
910 let line = CacheLine {
911 alias: "web1".to_string(),
912 timestamp: 1_700_000_000,
913 runtime: ContainerRuntime::Docker,
914 containers: vec![ContainerInfo {
915 id: "abc".to_string(),
916 names: "nginx".to_string(),
917 image: "nginx:latest".to_string(),
918 state: "running".to_string(),
919 status: "Up 2h".to_string(),
920 ports: "80/tcp".to_string(),
921 }],
922 };
923 let s = serde_json::to_string(&line).unwrap();
924 let d: CacheLine = serde_json::from_str(&s).unwrap();
925 assert_eq!(d.alias, "web1");
926 assert_eq!(d.runtime, ContainerRuntime::Docker);
927 assert_eq!(d.containers.len(), 1);
928 assert_eq!(d.containers[0].id, "abc");
929 }
930
931 #[test]
932 fn cache_round_trip_podman() {
933 let line = CacheLine {
934 alias: "host2".to_string(),
935 timestamp: 200,
936 runtime: ContainerRuntime::Podman,
937 containers: vec![],
938 };
939 let s = serde_json::to_string(&line).unwrap();
940 let d: CacheLine = serde_json::from_str(&s).unwrap();
941 assert_eq!(d.runtime, ContainerRuntime::Podman);
942 }
943
944 #[test]
945 fn cache_parse_empty() {
946 let map: HashMap<String, ContainerCacheEntry> =
947 "".lines().filter_map(parse_cache_line).collect();
948 assert!(map.is_empty());
949 }
950
951 #[test]
952 fn cache_parse_malformed_ignored() {
953 let valid = serde_json::to_string(&CacheLine {
954 alias: "good".to_string(),
955 timestamp: 1,
956 runtime: ContainerRuntime::Docker,
957 containers: vec![],
958 })
959 .unwrap();
960 let content = format!("garbage\n{valid}\nalso bad");
961 let map: HashMap<String, ContainerCacheEntry> =
962 content.lines().filter_map(parse_cache_line).collect();
963 assert_eq!(map.len(), 1);
964 assert!(map.contains_key("good"));
965 }
966
967 #[test]
968 fn cache_parse_multiple_hosts() {
969 let lines: Vec<String> = ["h1", "h2", "h3"]
970 .iter()
971 .enumerate()
972 .map(|(i, alias)| {
973 serde_json::to_string(&CacheLine {
974 alias: alias.to_string(),
975 timestamp: i as u64,
976 runtime: ContainerRuntime::Docker,
977 containers: vec![],
978 })
979 .unwrap()
980 })
981 .collect();
982 let content = lines.join("\n");
983 let map: HashMap<String, ContainerCacheEntry> =
984 content.lines().filter_map(parse_cache_line).collect();
985 assert_eq!(map.len(), 3);
986 }
987
988 fn parse_cache_line(line: &str) -> Option<(String, ContainerCacheEntry)> {
990 let t = line.trim();
991 if t.is_empty() {
992 return None;
993 }
994 let entry: CacheLine = serde_json::from_str(t).ok()?;
995 Some((
996 entry.alias,
997 ContainerCacheEntry {
998 timestamp: entry.timestamp,
999 runtime: entry.runtime,
1000 containers: entry.containers,
1001 },
1002 ))
1003 }
1004
1005 #[test]
1008 fn truncate_short() {
1009 assert_eq!(truncate_str("hi", 10), "hi");
1010 }
1011
1012 #[test]
1013 fn truncate_exact() {
1014 assert_eq!(truncate_str("hello", 5), "hello");
1015 }
1016
1017 #[test]
1018 fn truncate_long() {
1019 assert_eq!(truncate_str("hello world", 7), "hello..");
1020 }
1021
1022 #[test]
1023 fn truncate_empty() {
1024 assert_eq!(truncate_str("", 5), "");
1025 }
1026
1027 #[test]
1028 fn truncate_max_two() {
1029 assert_eq!(truncate_str("hello", 2), "..");
1030 }
1031
1032 #[test]
1033 fn truncate_multibyte() {
1034 assert_eq!(truncate_str("café-app", 6), "café..");
1035 }
1036
1037 #[test]
1038 fn truncate_emoji() {
1039 assert_eq!(truncate_str("🐳nginx", 5), "🐳ng..");
1040 }
1041
1042 fn now_secs() -> u64 {
1045 SystemTime::now()
1046 .duration_since(UNIX_EPOCH)
1047 .unwrap()
1048 .as_secs()
1049 }
1050
1051 #[test]
1052 fn relative_just_now() {
1053 assert_eq!(format_relative_time(now_secs()), "just now");
1054 assert_eq!(format_relative_time(now_secs() - 30), "just now");
1055 assert_eq!(format_relative_time(now_secs() - 59), "just now");
1056 }
1057
1058 #[test]
1059 fn relative_minutes() {
1060 assert_eq!(format_relative_time(now_secs() - 60), "1m ago");
1061 assert_eq!(format_relative_time(now_secs() - 300), "5m ago");
1062 assert_eq!(format_relative_time(now_secs() - 3599), "59m ago");
1063 }
1064
1065 #[test]
1066 fn relative_hours() {
1067 assert_eq!(format_relative_time(now_secs() - 3600), "1h ago");
1068 assert_eq!(format_relative_time(now_secs() - 7200), "2h ago");
1069 }
1070
1071 #[test]
1072 fn relative_days() {
1073 assert_eq!(format_relative_time(now_secs() - 86400), "1d ago");
1074 assert_eq!(format_relative_time(now_secs() - 7 * 86400), "7d ago");
1075 }
1076
1077 #[test]
1078 fn relative_future_saturates() {
1079 assert_eq!(format_relative_time(now_secs() + 10000), "just now");
1080 }
1081
1082 #[test]
1085 fn parse_ps_whitespace_only_lines_between_json() {
1086 let c1 = make_json("a", "web", "nginx", "running", "Up", "");
1087 let c2 = make_json("b", "db", "pg", "exited", "Exited", "");
1088 let input = format!("{c1}\n \n\t\n{c2}");
1089 let r = parse_container_ps(&input);
1090 assert_eq!(r.len(), 2);
1091 assert_eq!(r[0].id, "a");
1092 assert_eq!(r[1].id, "b");
1093 }
1094
1095 #[test]
1096 fn id_just_dot() {
1097 assert!(validate_container_id(".").is_ok());
1098 }
1099
1100 #[test]
1101 fn id_just_dash() {
1102 assert!(validate_container_id("-").is_ok());
1103 }
1104
1105 #[test]
1106 fn id_slash_rejected() {
1107 assert!(validate_container_id("my/container").is_err());
1108 }
1109
1110 #[test]
1111 fn list_cmd_none_valid_shell_syntax() {
1112 let cmd = container_list_command(None);
1113 assert!(cmd.contains("if "), "should start with if");
1114 assert!(cmd.contains("fi"), "should end with fi");
1115 assert!(cmd.contains("elif "), "should have elif fallback");
1116 assert!(cmd.contains("else "), "should have else branch");
1117 }
1118
1119 #[test]
1120 fn output_sentinel_on_last_line() {
1121 let r = parse_container_output("some MOTD\n##purple:docker##", None);
1122 let (rt, cs) = r.unwrap();
1123 assert_eq!(rt, ContainerRuntime::Docker);
1124 assert!(cs.is_empty());
1125 }
1126
1127 #[test]
1128 fn output_sentinel_none_on_last_line() {
1129 let r = parse_container_output("MOTD line\n##purple:none##", None);
1130 assert!(r.is_err());
1131 assert!(r.unwrap_err().contains("No container runtime"));
1132 }
1133
1134 #[test]
1135 fn relative_time_unix_epoch() {
1136 let result = format_relative_time(0);
1138 assert!(
1139 result.contains("d ago"),
1140 "epoch should be days ago: {result}"
1141 );
1142 }
1143
1144 #[test]
1145 fn truncate_unicode_within_limit() {
1146 assert_eq!(truncate_str("abc", 5), "abc"); }
1150
1151 #[test]
1152 fn truncate_ascii_boundary() {
1153 assert_eq!(truncate_str("hello", 0), "..");
1155 }
1156
1157 #[test]
1158 fn truncate_max_one() {
1159 assert_eq!(truncate_str("hello", 1), "..");
1160 }
1161
1162 #[test]
1163 fn cache_serde_unknown_runtime_rejected() {
1164 let json = r#"{"alias":"h","timestamp":1,"runtime":"Containerd","containers":[]}"#;
1165 let result = serde_json::from_str::<CacheLine>(json);
1166 assert!(result.is_err(), "unknown runtime should be rejected");
1167 }
1168
1169 #[test]
1170 fn cache_duplicate_alias_last_wins() {
1171 let line1 = serde_json::to_string(&CacheLine {
1172 alias: "dup".to_string(),
1173 timestamp: 1,
1174 runtime: ContainerRuntime::Docker,
1175 containers: vec![],
1176 })
1177 .unwrap();
1178 let line2 = serde_json::to_string(&CacheLine {
1179 alias: "dup".to_string(),
1180 timestamp: 99,
1181 runtime: ContainerRuntime::Podman,
1182 containers: vec![],
1183 })
1184 .unwrap();
1185 let content = format!("{line1}\n{line2}");
1186 let map: HashMap<String, ContainerCacheEntry> =
1187 content.lines().filter_map(parse_cache_line).collect();
1188 assert_eq!(map.len(), 1);
1189 assert_eq!(map["dup"].runtime, ContainerRuntime::Podman);
1191 assert_eq!(map["dup"].timestamp, 99);
1192 }
1193
1194 #[test]
1195 fn friendly_error_no_route() {
1196 let msg = friendly_container_error("ssh: No route to host", Some(255));
1197 assert_eq!(msg, "Host unreachable.");
1198 }
1199
1200 #[test]
1201 fn friendly_error_network_unreachable() {
1202 let msg = friendly_container_error("connect: Network is unreachable", Some(255));
1203 assert_eq!(msg, "Host unreachable.");
1204 }
1205
1206 #[test]
1207 fn friendly_error_none_exit_code() {
1208 let msg = friendly_container_error("", None);
1209 assert_eq!(msg, "Command failed with code 1.");
1210 }
1211
1212 #[test]
1213 fn container_error_display() {
1214 let err = ContainerError {
1215 runtime: Some(ContainerRuntime::Docker),
1216 message: "test error".to_string(),
1217 };
1218 assert_eq!(format!("{err}"), "test error");
1219 }
1220
1221 #[test]
1222 fn container_error_display_no_runtime() {
1223 let err = ContainerError {
1224 runtime: None,
1225 message: "no runtime".to_string(),
1226 };
1227 assert_eq!(format!("{err}"), "no runtime");
1228 }
1229
1230 #[test]
1233 fn parse_ps_crlf_line_endings() {
1234 let c1 = make_json("a", "web", "nginx", "running", "Up", "");
1235 let c2 = make_json("b", "db", "pg", "exited", "Exited", "");
1236 let input = format!("{c1}\r\n{c2}\r\n");
1237 let r = parse_container_ps(&input);
1238 assert_eq!(r.len(), 2);
1239 assert_eq!(r[0].id, "a");
1240 assert_eq!(r[1].id, "b");
1241 }
1242
1243 #[test]
1244 fn parse_ps_trailing_newline() {
1245 let c = make_json("a", "web", "nginx", "running", "Up", "");
1246 let input = format!("{c}\n");
1247 let r = parse_container_ps(&input);
1248 assert_eq!(
1249 r.len(),
1250 1,
1251 "trailing newline should not create phantom entry"
1252 );
1253 }
1254
1255 #[test]
1256 fn parse_ps_leading_whitespace_json() {
1257 let c = make_json("a", "web", "nginx", "running", "Up", "");
1258 let input = format!(" {c}");
1259 let r = parse_container_ps(&input);
1260 assert_eq!(
1261 r.len(),
1262 1,
1263 "leading whitespace before JSON should be trimmed"
1264 );
1265 assert_eq!(r[0].id, "a");
1266 }
1267
1268 #[test]
1271 fn parse_runtime_empty_lines_between_motd() {
1272 let input = "Welcome\n\n\n\ndocker";
1273 assert_eq!(parse_runtime(input), Some(ContainerRuntime::Docker));
1274 }
1275
1276 #[test]
1277 fn parse_runtime_crlf() {
1278 let input = "MOTD\r\npodman\r\n";
1279 assert_eq!(parse_runtime(input), Some(ContainerRuntime::Podman));
1280 }
1281
1282 #[test]
1285 fn output_unknown_sentinel() {
1286 let r = parse_container_output("##purple:unknown##", None);
1287 assert!(r.is_err());
1288 let msg = r.unwrap_err();
1289 assert!(msg.contains("Unknown sentinel"), "got: {msg}");
1290 }
1291
1292 #[test]
1293 fn output_sentinel_with_crlf() {
1294 let c = make_json("a", "web", "nginx", "running", "Up", "");
1295 let input = format!("##purple:docker##\r\n{c}\r\n");
1296 let (rt, cs) = parse_container_output(&input, None).unwrap();
1297 assert_eq!(rt, ContainerRuntime::Docker);
1298 assert_eq!(cs.len(), 1);
1299 }
1300
1301 #[test]
1302 fn output_sentinel_indented() {
1303 let c = make_json("a", "web", "nginx", "running", "Up", "");
1304 let input = format!(" ##purple:docker##\n{c}");
1305 let (rt, cs) = parse_container_output(&input, None).unwrap();
1306 assert_eq!(rt, ContainerRuntime::Docker);
1307 assert_eq!(cs.len(), 1);
1308 }
1309
1310 #[test]
1311 fn output_caller_runtime_podman() {
1312 let c = make_json("a", "app", "img", "running", "Up", "");
1313 let (rt, cs) = parse_container_output(&c, Some(ContainerRuntime::Podman)).unwrap();
1314 assert_eq!(rt, ContainerRuntime::Podman);
1315 assert_eq!(cs.len(), 1);
1316 }
1317
1318 #[test]
1321 fn action_command_long_id() {
1322 let long_id = "a".repeat(64);
1323 let cmd =
1324 container_action_command(ContainerRuntime::Docker, ContainerAction::Start, &long_id);
1325 assert_eq!(cmd, format!("docker start {long_id}"));
1326 }
1327
1328 #[test]
1331 fn id_full_sha256() {
1332 let id = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1333 assert_eq!(id.len(), 64);
1334 assert!(validate_container_id(id).is_ok());
1335 }
1336
1337 #[test]
1338 fn id_ampersand_rejected() {
1339 assert!(validate_container_id("app&rm").is_err());
1340 }
1341
1342 #[test]
1343 fn id_parentheses_rejected() {
1344 assert!(validate_container_id("app(1)").is_err());
1345 assert!(validate_container_id("app)").is_err());
1346 }
1347
1348 #[test]
1349 fn id_angle_brackets_rejected() {
1350 assert!(validate_container_id("app<1>").is_err());
1351 assert!(validate_container_id("app>").is_err());
1352 }
1353
1354 #[test]
1357 fn friendly_error_podman_daemon() {
1358 let msg = friendly_container_error("cannot connect to podman", Some(125));
1359 assert_eq!(msg, "Container daemon is not running.");
1360 }
1361
1362 #[test]
1363 fn friendly_error_case_insensitive() {
1364 let msg = friendly_container_error("PERMISSION DENIED", Some(1));
1365 assert_eq!(msg, "Permission denied. Is your user in the docker group?");
1366 }
1367
1368 #[test]
1371 fn container_runtime_copy() {
1372 let a = ContainerRuntime::Docker;
1373 let b = a; assert_eq!(a, b); }
1376
1377 #[test]
1378 fn container_action_copy() {
1379 let a = ContainerAction::Start;
1380 let b = a; assert_eq!(a, b); }
1383
1384 #[test]
1387 fn truncate_multibyte_utf8() {
1388 assert_eq!(truncate_str("caf\u{00e9}-app", 6), "caf\u{00e9}..");
1390 }
1391
1392 #[test]
1395 fn format_relative_time_boundary_60s() {
1396 let ts = now_secs() - 60;
1397 assert_eq!(format_relative_time(ts), "1m ago");
1398 }
1399
1400 #[test]
1401 fn format_relative_time_boundary_3600s() {
1402 let ts = now_secs() - 3600;
1403 assert_eq!(format_relative_time(ts), "1h ago");
1404 }
1405
1406 #[test]
1407 fn format_relative_time_boundary_86400s() {
1408 let ts = now_secs() - 86400;
1409 assert_eq!(format_relative_time(ts), "1d ago");
1410 }
1411
1412 #[test]
1415 fn container_error_debug() {
1416 let err = ContainerError {
1417 runtime: Some(ContainerRuntime::Docker),
1418 message: "test".to_string(),
1419 };
1420 let dbg = format!("{err:?}");
1421 assert!(
1422 dbg.contains("Docker"),
1423 "Debug should include runtime: {dbg}"
1424 );
1425 assert!(dbg.contains("test"), "Debug should include message: {dbg}");
1426 }
1427}