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