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