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 parse_container_cache_content(content: &str) -> HashMap<String, ContainerCacheEntry> {
402 let mut map = HashMap::new();
403 for line in content.lines() {
404 let trimmed = line.trim();
405 if trimmed.is_empty() {
406 continue;
407 }
408 if let Ok(entry) = serde_json::from_str::<CacheLine>(trimmed) {
409 map.insert(
410 entry.alias,
411 ContainerCacheEntry {
412 timestamp: entry.timestamp,
413 runtime: entry.runtime,
414 containers: entry.containers,
415 },
416 );
417 }
418 }
419 map
420}
421
422pub fn save_container_cache(cache: &HashMap<String, ContainerCacheEntry>) {
424 if crate::demo_flag::is_demo() {
425 return;
426 }
427 let Some(home) = dirs::home_dir() else {
428 return;
429 };
430 let path = home.join(".purple").join("container_cache.jsonl");
431 let mut lines = Vec::with_capacity(cache.len());
432 for (alias, entry) in cache {
433 let line = CacheLine {
434 alias: alias.clone(),
435 timestamp: entry.timestamp,
436 runtime: entry.runtime,
437 containers: entry.containers.clone(),
438 };
439 if let Ok(s) = serde_json::to_string(&line) {
440 lines.push(s);
441 }
442 }
443 let content = lines.join("\n");
444 let _ = crate::fs_util::atomic_write(&path, content.as_bytes());
445}
446
447pub fn truncate_str(s: &str, max: usize) -> String {
453 let count = s.chars().count();
454 if count <= max {
455 s.to_string()
456 } else {
457 let cut = max.saturating_sub(2);
458 let end = s.char_indices().nth(cut).map(|(i, _)| i).unwrap_or(s.len());
459 format!("{}..", &s[..end])
460 }
461}
462
463pub fn format_relative_time(timestamp: u64) -> String {
469 let now = SystemTime::now()
470 .duration_since(UNIX_EPOCH)
471 .unwrap_or_default()
472 .as_secs();
473 let diff = now.saturating_sub(timestamp);
474 if diff < 60 {
475 "just now".to_string()
476 } else if diff < 3600 {
477 format!("{}m ago", diff / 60)
478 } else if diff < 86400 {
479 format!("{}h ago", diff / 3600)
480 } else {
481 format!("{}d ago", diff / 86400)
482 }
483}
484
485#[cfg(test)]
490mod tests {
491 use super::*;
492
493 fn make_json(
494 id: &str,
495 names: &str,
496 image: &str,
497 state: &str,
498 status: &str,
499 ports: &str,
500 ) -> String {
501 serde_json::json!({
502 "ID": id,
503 "Names": names,
504 "Image": image,
505 "State": state,
506 "Status": status,
507 "Ports": ports,
508 })
509 .to_string()
510 }
511
512 #[test]
515 fn parse_ps_empty() {
516 assert!(parse_container_ps("").is_empty());
517 assert!(parse_container_ps(" \n \n").is_empty());
518 }
519
520 #[test]
521 fn parse_ps_single() {
522 let line = make_json("abc", "web", "nginx:latest", "running", "Up 2h", "80/tcp");
523 let r = parse_container_ps(&line);
524 assert_eq!(r.len(), 1);
525 assert_eq!(r[0].id, "abc");
526 assert_eq!(r[0].names, "web");
527 assert_eq!(r[0].image, "nginx:latest");
528 assert_eq!(r[0].state, "running");
529 }
530
531 #[test]
532 fn parse_ps_multiple() {
533 let lines = [
534 make_json("a", "web", "nginx", "running", "Up", "80/tcp"),
535 make_json("b", "db", "postgres", "exited", "Exited (0)", ""),
536 ];
537 let r = parse_container_ps(&lines.join("\n"));
538 assert_eq!(r.len(), 2);
539 }
540
541 #[test]
542 fn parse_ps_invalid_lines_ignored() {
543 let valid = make_json("x", "c", "i", "running", "Up", "");
544 let input = format!("garbage\n{valid}\nalso bad");
545 assert_eq!(parse_container_ps(&input).len(), 1);
546 }
547
548 #[test]
549 fn parse_ps_all_docker_states() {
550 for state in [
551 "created",
552 "restarting",
553 "running",
554 "removing",
555 "paused",
556 "exited",
557 "dead",
558 ] {
559 let line = make_json("id", "c", "img", state, "s", "");
560 let r = parse_container_ps(&line);
561 assert_eq!(r[0].state, state, "failed for {state}");
562 }
563 }
564
565 #[test]
566 fn parse_ps_compose_names() {
567 let line = make_json("a", "myproject-redis-1", "redis:7", "running", "Up", "");
568 assert_eq!(parse_container_ps(&line)[0].names, "myproject-redis-1");
569 }
570
571 #[test]
572 fn parse_ps_sha256_image() {
573 let line = make_json("a", "app", "sha256:abcdef123456", "running", "Up", "");
574 assert!(parse_container_ps(&line)[0].image.starts_with("sha256:"));
575 }
576
577 #[test]
578 fn parse_ps_long_ports() {
579 let ports = "0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, :::80->80/tcp";
580 let line = make_json("a", "proxy", "nginx", "running", "Up", ports);
581 assert_eq!(parse_container_ps(&line)[0].ports, ports);
582 }
583
584 #[test]
587 fn runtime_docker() {
588 assert_eq!(parse_runtime("docker"), Some(ContainerRuntime::Docker));
589 }
590
591 #[test]
592 fn runtime_podman() {
593 assert_eq!(parse_runtime("podman"), Some(ContainerRuntime::Podman));
594 }
595
596 #[test]
597 fn runtime_none() {
598 assert_eq!(parse_runtime(""), None);
599 assert_eq!(parse_runtime(" "), None);
600 assert_eq!(parse_runtime("unknown"), None);
601 assert_eq!(parse_runtime("Docker"), None); }
603
604 #[test]
605 fn runtime_motd_prepended() {
606 let input = "Welcome to Ubuntu 22.04\nSystem info\ndocker";
607 assert_eq!(parse_runtime(input), Some(ContainerRuntime::Docker));
608 }
609
610 #[test]
611 fn runtime_trailing_whitespace() {
612 assert_eq!(parse_runtime("docker "), Some(ContainerRuntime::Docker));
613 assert_eq!(parse_runtime("podman\t"), Some(ContainerRuntime::Podman));
614 }
615
616 #[test]
617 fn runtime_motd_after_output() {
618 let input = "docker\nSystem update available.";
619 assert_eq!(parse_runtime(input), None);
621 }
622
623 #[test]
626 fn action_command_all_combinations() {
627 let cases = [
628 (
629 ContainerRuntime::Docker,
630 ContainerAction::Start,
631 "docker start c1",
632 ),
633 (
634 ContainerRuntime::Docker,
635 ContainerAction::Stop,
636 "docker stop c1",
637 ),
638 (
639 ContainerRuntime::Docker,
640 ContainerAction::Restart,
641 "docker restart c1",
642 ),
643 (
644 ContainerRuntime::Podman,
645 ContainerAction::Start,
646 "podman start c1",
647 ),
648 (
649 ContainerRuntime::Podman,
650 ContainerAction::Stop,
651 "podman stop c1",
652 ),
653 (
654 ContainerRuntime::Podman,
655 ContainerAction::Restart,
656 "podman restart c1",
657 ),
658 ];
659 for (rt, action, expected) in cases {
660 assert_eq!(container_action_command(rt, action, "c1"), expected);
661 }
662 }
663
664 #[test]
665 fn action_as_str() {
666 assert_eq!(ContainerAction::Start.as_str(), "start");
667 assert_eq!(ContainerAction::Stop.as_str(), "stop");
668 assert_eq!(ContainerAction::Restart.as_str(), "restart");
669 }
670
671 #[test]
672 fn runtime_as_str() {
673 assert_eq!(ContainerRuntime::Docker.as_str(), "docker");
674 assert_eq!(ContainerRuntime::Podman.as_str(), "podman");
675 }
676
677 #[test]
680 fn id_valid_hex() {
681 assert!(validate_container_id("a1b2c3d4e5f6").is_ok());
682 }
683
684 #[test]
685 fn id_valid_names() {
686 assert!(validate_container_id("myapp").is_ok());
687 assert!(validate_container_id("my-app").is_ok());
688 assert!(validate_container_id("my_app").is_ok());
689 assert!(validate_container_id("my.app").is_ok());
690 assert!(validate_container_id("myproject-web-1").is_ok());
691 }
692
693 #[test]
694 fn id_empty() {
695 assert!(validate_container_id("").is_err());
696 }
697
698 #[test]
699 fn id_space() {
700 assert!(validate_container_id("my app").is_err());
701 }
702
703 #[test]
704 fn id_newline() {
705 assert!(validate_container_id("app\n").is_err());
706 }
707
708 #[test]
709 fn id_injection_semicolon() {
710 assert!(validate_container_id("app;rm -rf /").is_err());
711 }
712
713 #[test]
714 fn id_injection_pipe() {
715 assert!(validate_container_id("app|cat /etc/passwd").is_err());
716 }
717
718 #[test]
719 fn id_injection_dollar() {
720 assert!(validate_container_id("app$HOME").is_err());
721 }
722
723 #[test]
724 fn id_injection_backtick() {
725 assert!(validate_container_id("app`whoami`").is_err());
726 }
727
728 #[test]
729 fn id_unicode_rejected() {
730 assert!(validate_container_id("app\u{00e9}").is_err());
731 assert!(validate_container_id("\u{0430}pp").is_err()); }
733
734 #[test]
735 fn id_colon_rejected() {
736 assert!(validate_container_id("app:latest").is_err());
737 }
738
739 #[test]
742 fn list_cmd_docker() {
743 assert_eq!(
744 container_list_command(Some(ContainerRuntime::Docker)),
745 "docker ps -a --format '{{json .}}'"
746 );
747 }
748
749 #[test]
750 fn list_cmd_podman() {
751 assert_eq!(
752 container_list_command(Some(ContainerRuntime::Podman)),
753 "podman ps -a --format '{{json .}}'"
754 );
755 }
756
757 #[test]
758 fn list_cmd_none_has_sentinels() {
759 let cmd = container_list_command(None);
760 assert!(cmd.contains("##purple:docker##"));
761 assert!(cmd.contains("##purple:podman##"));
762 assert!(cmd.contains("##purple:none##"));
763 }
764
765 #[test]
766 fn list_cmd_none_docker_first() {
767 let cmd = container_list_command(None);
768 let d = cmd.find("##purple:docker##").unwrap();
769 let p = cmd.find("##purple:podman##").unwrap();
770 assert!(d < p);
771 }
772
773 #[test]
776 fn output_docker_sentinel() {
777 let c = make_json("abc", "web", "nginx", "running", "Up", "80/tcp");
778 let out = format!("##purple:docker##\n{c}");
779 let (rt, cs) = parse_container_output(&out, None).unwrap();
780 assert_eq!(rt, ContainerRuntime::Docker);
781 assert_eq!(cs.len(), 1);
782 }
783
784 #[test]
785 fn output_podman_sentinel() {
786 let c = make_json("xyz", "db", "pg", "exited", "Exited", "");
787 let out = format!("##purple:podman##\n{c}");
788 let (rt, _) = parse_container_output(&out, None).unwrap();
789 assert_eq!(rt, ContainerRuntime::Podman);
790 }
791
792 #[test]
793 fn output_none_sentinel() {
794 let r = parse_container_output("##purple:none##", None);
795 assert!(r.is_err());
796 assert!(r.unwrap_err().contains("No container runtime"));
797 }
798
799 #[test]
800 fn output_no_sentinel_with_caller() {
801 let c = make_json("a", "app", "img", "running", "Up", "");
802 let (rt, cs) = parse_container_output(&c, Some(ContainerRuntime::Docker)).unwrap();
803 assert_eq!(rt, ContainerRuntime::Docker);
804 assert_eq!(cs.len(), 1);
805 }
806
807 #[test]
808 fn output_no_sentinel_no_caller() {
809 let c = make_json("a", "app", "img", "running", "Up", "");
810 assert!(parse_container_output(&c, None).is_err());
811 }
812
813 #[test]
814 fn output_motd_before_sentinel() {
815 let c = make_json("a", "app", "img", "running", "Up", "");
816 let out = format!("Welcome to server\nInfo line\n##purple:docker##\n{c}");
817 let (rt, cs) = parse_container_output(&out, None).unwrap();
818 assert_eq!(rt, ContainerRuntime::Docker);
819 assert_eq!(cs.len(), 1);
820 }
821
822 #[test]
823 fn output_empty_container_list() {
824 let (rt, cs) = parse_container_output("##purple:docker##\n", None).unwrap();
825 assert_eq!(rt, ContainerRuntime::Docker);
826 assert!(cs.is_empty());
827 }
828
829 #[test]
830 fn output_multiple_containers() {
831 let c1 = make_json("a", "web", "nginx", "running", "Up", "80/tcp");
832 let c2 = make_json("b", "db", "pg", "exited", "Exited", "");
833 let c3 = make_json("c", "cache", "redis", "running", "Up", "6379/tcp");
834 let out = format!("##purple:podman##\n{c1}\n{c2}\n{c3}");
835 let (_, cs) = parse_container_output(&out, None).unwrap();
836 assert_eq!(cs.len(), 3);
837 }
838
839 #[test]
842 fn friendly_error_command_not_found() {
843 let msg = friendly_container_error("bash: docker: command not found", Some(127));
844 assert_eq!(msg, "Docker or Podman not found on remote host.");
845 }
846
847 #[test]
848 fn friendly_error_permission_denied() {
849 let msg = friendly_container_error(
850 "Got permission denied while trying to connect to the Docker daemon socket",
851 Some(1),
852 );
853 assert_eq!(msg, "Permission denied. Is your user in the docker group?");
854 }
855
856 #[test]
857 fn friendly_error_daemon_not_running() {
858 let msg = friendly_container_error(
859 "Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
860 Some(1),
861 );
862 assert_eq!(msg, "Container daemon is not running.");
863 }
864
865 #[test]
866 fn friendly_error_connection_refused() {
867 let msg = friendly_container_error("ssh: connect to host: Connection refused", Some(255));
868 assert_eq!(msg, "Connection refused.");
869 }
870
871 #[test]
872 fn friendly_error_empty_stderr() {
873 let msg = friendly_container_error("", Some(1));
874 assert_eq!(msg, "Command failed with code 1.");
875 }
876
877 #[test]
878 fn friendly_error_unknown_stderr_uses_generic_message() {
879 let msg = friendly_container_error("some unknown error", Some(1));
880 assert_eq!(msg, "Command failed with code 1.");
881 }
882
883 #[test]
886 fn cache_round_trip() {
887 let line = CacheLine {
888 alias: "web1".to_string(),
889 timestamp: 1_700_000_000,
890 runtime: ContainerRuntime::Docker,
891 containers: vec![ContainerInfo {
892 id: "abc".to_string(),
893 names: "nginx".to_string(),
894 image: "nginx:latest".to_string(),
895 state: "running".to_string(),
896 status: "Up 2h".to_string(),
897 ports: "80/tcp".to_string(),
898 }],
899 };
900 let s = serde_json::to_string(&line).unwrap();
901 let d: CacheLine = serde_json::from_str(&s).unwrap();
902 assert_eq!(d.alias, "web1");
903 assert_eq!(d.runtime, ContainerRuntime::Docker);
904 assert_eq!(d.containers.len(), 1);
905 assert_eq!(d.containers[0].id, "abc");
906 }
907
908 #[test]
909 fn cache_round_trip_podman() {
910 let line = CacheLine {
911 alias: "host2".to_string(),
912 timestamp: 200,
913 runtime: ContainerRuntime::Podman,
914 containers: vec![],
915 };
916 let s = serde_json::to_string(&line).unwrap();
917 let d: CacheLine = serde_json::from_str(&s).unwrap();
918 assert_eq!(d.runtime, ContainerRuntime::Podman);
919 }
920
921 #[test]
922 fn cache_parse_empty() {
923 let map: HashMap<String, ContainerCacheEntry> =
924 "".lines().filter_map(parse_cache_line).collect();
925 assert!(map.is_empty());
926 }
927
928 #[test]
929 fn cache_parse_malformed_ignored() {
930 let valid = serde_json::to_string(&CacheLine {
931 alias: "good".to_string(),
932 timestamp: 1,
933 runtime: ContainerRuntime::Docker,
934 containers: vec![],
935 })
936 .unwrap();
937 let content = format!("garbage\n{valid}\nalso bad");
938 let map: HashMap<String, ContainerCacheEntry> =
939 content.lines().filter_map(parse_cache_line).collect();
940 assert_eq!(map.len(), 1);
941 assert!(map.contains_key("good"));
942 }
943
944 #[test]
945 fn cache_parse_multiple_hosts() {
946 let lines: Vec<String> = ["h1", "h2", "h3"]
947 .iter()
948 .enumerate()
949 .map(|(i, alias)| {
950 serde_json::to_string(&CacheLine {
951 alias: alias.to_string(),
952 timestamp: i as u64,
953 runtime: ContainerRuntime::Docker,
954 containers: vec![],
955 })
956 .unwrap()
957 })
958 .collect();
959 let content = lines.join("\n");
960 let map: HashMap<String, ContainerCacheEntry> =
961 content.lines().filter_map(parse_cache_line).collect();
962 assert_eq!(map.len(), 3);
963 }
964
965 fn parse_cache_line(line: &str) -> Option<(String, ContainerCacheEntry)> {
967 let t = line.trim();
968 if t.is_empty() {
969 return None;
970 }
971 let entry: CacheLine = serde_json::from_str(t).ok()?;
972 Some((
973 entry.alias,
974 ContainerCacheEntry {
975 timestamp: entry.timestamp,
976 runtime: entry.runtime,
977 containers: entry.containers,
978 },
979 ))
980 }
981
982 #[test]
985 fn truncate_short() {
986 assert_eq!(truncate_str("hi", 10), "hi");
987 }
988
989 #[test]
990 fn truncate_exact() {
991 assert_eq!(truncate_str("hello", 5), "hello");
992 }
993
994 #[test]
995 fn truncate_long() {
996 assert_eq!(truncate_str("hello world", 7), "hello..");
997 }
998
999 #[test]
1000 fn truncate_empty() {
1001 assert_eq!(truncate_str("", 5), "");
1002 }
1003
1004 #[test]
1005 fn truncate_max_two() {
1006 assert_eq!(truncate_str("hello", 2), "..");
1007 }
1008
1009 #[test]
1010 fn truncate_multibyte() {
1011 assert_eq!(truncate_str("café-app", 6), "café..");
1012 }
1013
1014 #[test]
1015 fn truncate_emoji() {
1016 assert_eq!(truncate_str("🐳nginx", 5), "🐳ng..");
1017 }
1018
1019 fn now_secs() -> u64 {
1022 SystemTime::now()
1023 .duration_since(UNIX_EPOCH)
1024 .unwrap()
1025 .as_secs()
1026 }
1027
1028 #[test]
1029 fn relative_just_now() {
1030 assert_eq!(format_relative_time(now_secs()), "just now");
1031 assert_eq!(format_relative_time(now_secs() - 30), "just now");
1032 assert_eq!(format_relative_time(now_secs() - 59), "just now");
1033 }
1034
1035 #[test]
1036 fn relative_minutes() {
1037 assert_eq!(format_relative_time(now_secs() - 60), "1m ago");
1038 assert_eq!(format_relative_time(now_secs() - 300), "5m ago");
1039 assert_eq!(format_relative_time(now_secs() - 3599), "59m ago");
1040 }
1041
1042 #[test]
1043 fn relative_hours() {
1044 assert_eq!(format_relative_time(now_secs() - 3600), "1h ago");
1045 assert_eq!(format_relative_time(now_secs() - 7200), "2h ago");
1046 }
1047
1048 #[test]
1049 fn relative_days() {
1050 assert_eq!(format_relative_time(now_secs() - 86400), "1d ago");
1051 assert_eq!(format_relative_time(now_secs() - 7 * 86400), "7d ago");
1052 }
1053
1054 #[test]
1055 fn relative_future_saturates() {
1056 assert_eq!(format_relative_time(now_secs() + 10000), "just now");
1057 }
1058
1059 #[test]
1062 fn parse_ps_whitespace_only_lines_between_json() {
1063 let c1 = make_json("a", "web", "nginx", "running", "Up", "");
1064 let c2 = make_json("b", "db", "pg", "exited", "Exited", "");
1065 let input = format!("{c1}\n \n\t\n{c2}");
1066 let r = parse_container_ps(&input);
1067 assert_eq!(r.len(), 2);
1068 assert_eq!(r[0].id, "a");
1069 assert_eq!(r[1].id, "b");
1070 }
1071
1072 #[test]
1073 fn id_just_dot() {
1074 assert!(validate_container_id(".").is_ok());
1075 }
1076
1077 #[test]
1078 fn id_just_dash() {
1079 assert!(validate_container_id("-").is_ok());
1080 }
1081
1082 #[test]
1083 fn id_slash_rejected() {
1084 assert!(validate_container_id("my/container").is_err());
1085 }
1086
1087 #[test]
1088 fn list_cmd_none_valid_shell_syntax() {
1089 let cmd = container_list_command(None);
1090 assert!(cmd.contains("if "), "should start with if");
1091 assert!(cmd.contains("fi"), "should end with fi");
1092 assert!(cmd.contains("elif "), "should have elif fallback");
1093 assert!(cmd.contains("else "), "should have else branch");
1094 }
1095
1096 #[test]
1097 fn output_sentinel_on_last_line() {
1098 let r = parse_container_output("some MOTD\n##purple:docker##", None);
1099 let (rt, cs) = r.unwrap();
1100 assert_eq!(rt, ContainerRuntime::Docker);
1101 assert!(cs.is_empty());
1102 }
1103
1104 #[test]
1105 fn output_sentinel_none_on_last_line() {
1106 let r = parse_container_output("MOTD line\n##purple:none##", None);
1107 assert!(r.is_err());
1108 assert!(r.unwrap_err().contains("No container runtime"));
1109 }
1110
1111 #[test]
1112 fn relative_time_unix_epoch() {
1113 let result = format_relative_time(0);
1115 assert!(
1116 result.contains("d ago"),
1117 "epoch should be days ago: {result}"
1118 );
1119 }
1120
1121 #[test]
1122 fn truncate_unicode_within_limit() {
1123 assert_eq!(truncate_str("abc", 5), "abc"); }
1127
1128 #[test]
1129 fn truncate_ascii_boundary() {
1130 assert_eq!(truncate_str("hello", 0), "..");
1132 }
1133
1134 #[test]
1135 fn truncate_max_one() {
1136 assert_eq!(truncate_str("hello", 1), "..");
1137 }
1138
1139 #[test]
1140 fn cache_serde_unknown_runtime_rejected() {
1141 let json = r#"{"alias":"h","timestamp":1,"runtime":"Containerd","containers":[]}"#;
1142 let result = serde_json::from_str::<CacheLine>(json);
1143 assert!(result.is_err(), "unknown runtime should be rejected");
1144 }
1145
1146 #[test]
1147 fn cache_duplicate_alias_last_wins() {
1148 let line1 = serde_json::to_string(&CacheLine {
1149 alias: "dup".to_string(),
1150 timestamp: 1,
1151 runtime: ContainerRuntime::Docker,
1152 containers: vec![],
1153 })
1154 .unwrap();
1155 let line2 = serde_json::to_string(&CacheLine {
1156 alias: "dup".to_string(),
1157 timestamp: 99,
1158 runtime: ContainerRuntime::Podman,
1159 containers: vec![],
1160 })
1161 .unwrap();
1162 let content = format!("{line1}\n{line2}");
1163 let map: HashMap<String, ContainerCacheEntry> =
1164 content.lines().filter_map(parse_cache_line).collect();
1165 assert_eq!(map.len(), 1);
1166 assert_eq!(map["dup"].runtime, ContainerRuntime::Podman);
1168 assert_eq!(map["dup"].timestamp, 99);
1169 }
1170
1171 #[test]
1172 fn friendly_error_no_route() {
1173 let msg = friendly_container_error("ssh: No route to host", Some(255));
1174 assert_eq!(msg, "Host unreachable.");
1175 }
1176
1177 #[test]
1178 fn friendly_error_network_unreachable() {
1179 let msg = friendly_container_error("connect: Network is unreachable", Some(255));
1180 assert_eq!(msg, "Host unreachable.");
1181 }
1182
1183 #[test]
1184 fn friendly_error_none_exit_code() {
1185 let msg = friendly_container_error("", None);
1186 assert_eq!(msg, "Command failed with code 1.");
1187 }
1188
1189 #[test]
1190 fn container_error_display() {
1191 let err = ContainerError {
1192 runtime: Some(ContainerRuntime::Docker),
1193 message: "test error".to_string(),
1194 };
1195 assert_eq!(format!("{err}"), "test error");
1196 }
1197
1198 #[test]
1199 fn container_error_display_no_runtime() {
1200 let err = ContainerError {
1201 runtime: None,
1202 message: "no runtime".to_string(),
1203 };
1204 assert_eq!(format!("{err}"), "no runtime");
1205 }
1206
1207 #[test]
1210 fn parse_ps_crlf_line_endings() {
1211 let c1 = make_json("a", "web", "nginx", "running", "Up", "");
1212 let c2 = make_json("b", "db", "pg", "exited", "Exited", "");
1213 let input = format!("{c1}\r\n{c2}\r\n");
1214 let r = parse_container_ps(&input);
1215 assert_eq!(r.len(), 2);
1216 assert_eq!(r[0].id, "a");
1217 assert_eq!(r[1].id, "b");
1218 }
1219
1220 #[test]
1221 fn parse_ps_trailing_newline() {
1222 let c = make_json("a", "web", "nginx", "running", "Up", "");
1223 let input = format!("{c}\n");
1224 let r = parse_container_ps(&input);
1225 assert_eq!(
1226 r.len(),
1227 1,
1228 "trailing newline should not create phantom entry"
1229 );
1230 }
1231
1232 #[test]
1233 fn parse_ps_leading_whitespace_json() {
1234 let c = make_json("a", "web", "nginx", "running", "Up", "");
1235 let input = format!(" {c}");
1236 let r = parse_container_ps(&input);
1237 assert_eq!(
1238 r.len(),
1239 1,
1240 "leading whitespace before JSON should be trimmed"
1241 );
1242 assert_eq!(r[0].id, "a");
1243 }
1244
1245 #[test]
1248 fn parse_runtime_empty_lines_between_motd() {
1249 let input = "Welcome\n\n\n\ndocker";
1250 assert_eq!(parse_runtime(input), Some(ContainerRuntime::Docker));
1251 }
1252
1253 #[test]
1254 fn parse_runtime_crlf() {
1255 let input = "MOTD\r\npodman\r\n";
1256 assert_eq!(parse_runtime(input), Some(ContainerRuntime::Podman));
1257 }
1258
1259 #[test]
1262 fn output_unknown_sentinel() {
1263 let r = parse_container_output("##purple:unknown##", None);
1264 assert!(r.is_err());
1265 let msg = r.unwrap_err();
1266 assert!(msg.contains("Unknown sentinel"), "got: {msg}");
1267 }
1268
1269 #[test]
1270 fn output_sentinel_with_crlf() {
1271 let c = make_json("a", "web", "nginx", "running", "Up", "");
1272 let input = format!("##purple:docker##\r\n{c}\r\n");
1273 let (rt, cs) = parse_container_output(&input, None).unwrap();
1274 assert_eq!(rt, ContainerRuntime::Docker);
1275 assert_eq!(cs.len(), 1);
1276 }
1277
1278 #[test]
1279 fn output_sentinel_indented() {
1280 let c = make_json("a", "web", "nginx", "running", "Up", "");
1281 let input = format!(" ##purple:docker##\n{c}");
1282 let (rt, cs) = parse_container_output(&input, None).unwrap();
1283 assert_eq!(rt, ContainerRuntime::Docker);
1284 assert_eq!(cs.len(), 1);
1285 }
1286
1287 #[test]
1288 fn output_caller_runtime_podman() {
1289 let c = make_json("a", "app", "img", "running", "Up", "");
1290 let (rt, cs) = parse_container_output(&c, Some(ContainerRuntime::Podman)).unwrap();
1291 assert_eq!(rt, ContainerRuntime::Podman);
1292 assert_eq!(cs.len(), 1);
1293 }
1294
1295 #[test]
1298 fn action_command_long_id() {
1299 let long_id = "a".repeat(64);
1300 let cmd =
1301 container_action_command(ContainerRuntime::Docker, ContainerAction::Start, &long_id);
1302 assert_eq!(cmd, format!("docker start {long_id}"));
1303 }
1304
1305 #[test]
1308 fn id_full_sha256() {
1309 let id = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1310 assert_eq!(id.len(), 64);
1311 assert!(validate_container_id(id).is_ok());
1312 }
1313
1314 #[test]
1315 fn id_ampersand_rejected() {
1316 assert!(validate_container_id("app&rm").is_err());
1317 }
1318
1319 #[test]
1320 fn id_parentheses_rejected() {
1321 assert!(validate_container_id("app(1)").is_err());
1322 assert!(validate_container_id("app)").is_err());
1323 }
1324
1325 #[test]
1326 fn id_angle_brackets_rejected() {
1327 assert!(validate_container_id("app<1>").is_err());
1328 assert!(validate_container_id("app>").is_err());
1329 }
1330
1331 #[test]
1334 fn friendly_error_podman_daemon() {
1335 let msg = friendly_container_error("cannot connect to podman", Some(125));
1336 assert_eq!(msg, "Container daemon is not running.");
1337 }
1338
1339 #[test]
1340 fn friendly_error_case_insensitive() {
1341 let msg = friendly_container_error("PERMISSION DENIED", Some(1));
1342 assert_eq!(msg, "Permission denied. Is your user in the docker group?");
1343 }
1344
1345 #[test]
1348 fn container_runtime_copy() {
1349 let a = ContainerRuntime::Docker;
1350 let b = a; assert_eq!(a, b); }
1353
1354 #[test]
1355 fn container_action_copy() {
1356 let a = ContainerAction::Start;
1357 let b = a; assert_eq!(a, b); }
1360
1361 #[test]
1364 fn truncate_multibyte_utf8() {
1365 assert_eq!(truncate_str("caf\u{00e9}-app", 6), "caf\u{00e9}..");
1367 }
1368
1369 #[test]
1372 fn format_relative_time_boundary_60s() {
1373 let ts = now_secs() - 60;
1374 assert_eq!(format_relative_time(ts), "1m ago");
1375 }
1376
1377 #[test]
1378 fn format_relative_time_boundary_3600s() {
1379 let ts = now_secs() - 3600;
1380 assert_eq!(format_relative_time(ts), "1h ago");
1381 }
1382
1383 #[test]
1384 fn format_relative_time_boundary_86400s() {
1385 let ts = now_secs() - 86400;
1386 assert_eq!(format_relative_time(ts), "1d ago");
1387 }
1388
1389 #[test]
1392 fn container_error_debug() {
1393 let err = ContainerError {
1394 runtime: Some(ContainerRuntime::Docker),
1395 message: "test".to_string(),
1396 };
1397 let dbg = format!("{err:?}");
1398 assert!(
1399 dbg.contains("Docker"),
1400 "Debug should include runtime: {dbg}"
1401 );
1402 assert!(dbg.contains("test"), "Debug should include message: {dbg}");
1403 }
1404}