Skip to main content

prt_core/core/
container.rs

1//! Container name resolution for Docker/Podman.
2//!
3//! Resolves process PIDs to container names using:
4//! - **Linux:** `/proc/{pid}/cgroup` → container ID → `docker ps` lookup
5//! - **macOS:** `docker ps` with PID matching via `docker inspect`
6//!
7//! All lookups are batched per refresh cycle to minimize CLI overhead.
8//! Missing Docker/Podman is handled gracefully (empty results, no errors).
9
10use std::collections::HashMap;
11use std::process::Command;
12use std::time::Duration;
13
14/// Timeout for docker CLI calls to avoid blocking the TUI.
15const DOCKER_TIMEOUT_SECS: u64 = 2;
16
17/// Resolve container names for a batch of PIDs.
18///
19/// Returns a map of PID → container name. PIDs not running in a
20/// container are simply absent from the result. If Docker/Podman
21/// is unavailable, returns an empty map.
22pub fn resolve_container_names(pids: &[u32]) -> HashMap<u32, String> {
23    if pids.is_empty() {
24        return HashMap::new();
25    }
26
27    // Try Docker first, fall back to Podman
28    docker_resolve(pids)
29        .or_else(|| podman_resolve(pids))
30        .unwrap_or_default()
31}
32
33/// Check if any entries have container names (used for adaptive column).
34pub fn has_containers(names: &HashMap<u32, String>) -> bool {
35    !names.is_empty()
36}
37
38/// Resolve via `docker ps` + `docker inspect`.
39fn docker_resolve(pids: &[u32]) -> Option<HashMap<u32, String>> {
40    // Get all running containers: ID and Name
41    let output = run_with_timeout(
42        "docker",
43        &["ps", "--no-trunc", "--format", "{{.ID}} {{.Names}}"],
44    )?;
45
46    if output.is_empty() {
47        return Some(HashMap::new());
48    }
49
50    let containers: Vec<(String, String)> = output
51        .lines()
52        .filter_map(|line| {
53            let mut parts = line.splitn(2, ' ');
54            let id = parts.next()?.trim().to_string();
55            let name = parts.next()?.trim().to_string();
56            if id.is_empty() || name.is_empty() {
57                None
58            } else {
59                Some((id, name))
60            }
61        })
62        .collect();
63
64    if containers.is_empty() {
65        return Some(HashMap::new());
66    }
67
68    // For each container, get its PID
69    let mut result = HashMap::new();
70    for (id, name) in &containers {
71        if let Some(container_pid) = get_container_pid("docker", id) {
72            if pids.contains(&container_pid) {
73                result.insert(container_pid, name.clone());
74            }
75        }
76    }
77
78    Some(result)
79}
80
81/// Resolve via `podman ps` + `podman inspect`.
82fn podman_resolve(pids: &[u32]) -> Option<HashMap<u32, String>> {
83    let output = run_with_timeout(
84        "podman",
85        &["ps", "--no-trunc", "--format", "{{.ID}} {{.Names}}"],
86    )?;
87
88    if output.is_empty() {
89        return Some(HashMap::new());
90    }
91
92    let containers: Vec<(String, String)> = output
93        .lines()
94        .filter_map(|line| {
95            let mut parts = line.splitn(2, ' ');
96            let id = parts.next()?.trim().to_string();
97            let name = parts.next()?.trim().to_string();
98            if id.is_empty() || name.is_empty() {
99                None
100            } else {
101                Some((id, name))
102            }
103        })
104        .collect();
105
106    if containers.is_empty() {
107        return Some(HashMap::new());
108    }
109
110    let mut result = HashMap::new();
111    for (id, name) in &containers {
112        if let Some(container_pid) = get_container_pid("podman", id) {
113            if pids.contains(&container_pid) {
114                result.insert(container_pid, name.clone());
115            }
116        }
117    }
118
119    Some(result)
120}
121
122/// Get the main PID of a container via `docker/podman inspect`.
123fn get_container_pid(runtime: &str, container_id: &str) -> Option<u32> {
124    let output = run_with_timeout(
125        runtime,
126        &["inspect", "--format", "{{.State.Pid}}", container_id],
127    )?;
128    output.trim().parse().ok().filter(|&pid: &u32| pid > 0)
129}
130
131/// Run a command with timeout, returning stdout as String.
132/// Returns None if command not found, timeout, or non-zero exit.
133fn run_with_timeout(cmd: &str, args: &[&str]) -> Option<String> {
134    let mut child = Command::new(cmd)
135        .args(args)
136        .stdout(std::process::Stdio::piped())
137        .stderr(std::process::Stdio::null())
138        .spawn()
139        .ok()?;
140
141    let timeout = Duration::from_secs(DOCKER_TIMEOUT_SECS);
142    let start = std::time::Instant::now();
143
144    loop {
145        match child.try_wait() {
146            Ok(Some(status)) => {
147                if !status.success() {
148                    return None;
149                }
150                // Child already exited — read stdout directly (not wait_with_output,
151                // which would double-wait and potentially deadlock).
152                let mut out = String::new();
153                if let Some(mut stdout) = child.stdout.take() {
154                    use std::io::Read;
155                    let _ = stdout.read_to_string(&mut out);
156                }
157                return if out.is_empty() { None } else { Some(out) };
158            }
159            Ok(None) => {
160                if start.elapsed() > timeout {
161                    let _ = child.kill();
162                    return None;
163                }
164                std::thread::sleep(Duration::from_millis(50));
165            }
166            Err(_) => return None,
167        }
168    }
169}
170
171/// Parse a `docker ps` line into (id, name).
172#[cfg(test)]
173fn parse_ps_line(line: &str) -> Option<(String, String)> {
174    let mut parts = line.splitn(2, ' ');
175    let id = parts.next()?.trim().to_string();
176    let name = parts.next()?.trim().to_string();
177    if id.is_empty() || name.is_empty() {
178        None
179    } else {
180        Some((id, name))
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn parse_ps_line_valid() {
190        let (id, name) = parse_ps_line("abc123def456 my-nginx").unwrap();
191        assert_eq!(id, "abc123def456");
192        assert_eq!(name, "my-nginx");
193    }
194
195    #[test]
196    fn parse_ps_line_with_spaces_in_name() {
197        // Docker names don't have spaces, but ensure parsing is robust
198        let (id, name) = parse_ps_line("abc123 my container").unwrap();
199        assert_eq!(id, "abc123");
200        assert_eq!(name, "my container");
201    }
202
203    #[test]
204    fn parse_ps_line_empty_returns_none() {
205        assert!(parse_ps_line("").is_none());
206        assert!(parse_ps_line(" ").is_none());
207    }
208
209    #[test]
210    fn parse_ps_line_no_name_returns_none() {
211        assert!(parse_ps_line("abc123").is_none());
212    }
213
214    #[test]
215    fn resolve_empty_pids() {
216        let result = resolve_container_names(&[]);
217        assert!(result.is_empty());
218    }
219
220    #[test]
221    fn has_containers_empty() {
222        assert!(!has_containers(&HashMap::new()));
223    }
224
225    #[test]
226    fn has_containers_with_data() {
227        let mut m = HashMap::new();
228        m.insert(1, "nginx".to_string());
229        assert!(has_containers(&m));
230    }
231}