Skip to main content

horto_os_ui_shared/kits/
docker.rs

1//! Docker helpers for status listing and compose rebuild.
2//!
3//! reference: horto-os/scripts/docker_rebuild.sh
4
5use crate::error::{HortoError, Result};
6use serde::{Deserialize, Serialize};
7use std::process::Command;
8
9/// One running container row from `docker ps`.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct ContainerInfo {
12    /// Short or full container id.
13    pub id: String,
14    /// Comma-separated names.
15    pub names: String,
16    /// Image reference.
17    pub image: String,
18    /// Status column from `docker ps`.
19    pub status: String,
20    /// Ports column (may be empty).
21    pub ports: String,
22    /// Compose project / Dockge stack name when known.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub stack: Option<String>,
25    /// Optional catalog blurb for known Horto images / names.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub description: Option<String>,
28}
29
30/// Whether `docker` is on `PATH`.
31#[must_use]
32pub fn docker_available() -> bool {
33    which::which("docker").is_ok()
34}
35
36/// Parse `docker ps --format` tab-separated lines into [`ContainerInfo`] rows.
37///
38/// Columns: id, names, image, status, ports (optional), compose project (optional).
39///
40/// # Examples
41///
42/// ```
43/// use horto_os_ui_shared::kits::docker::parse_docker_ps_lines;
44///
45/// let rows = parse_docker_ps_lines("abc\tweb\tnginx:latest\tUp 1h\t80/tcp\thomepage\n");
46/// assert_eq!(rows.len(), 1);
47/// assert_eq!(rows[0].names, "web");
48/// assert_eq!(rows[0].stack.as_deref(), Some("homepage"));
49/// ```
50#[must_use]
51pub fn parse_docker_ps_lines(text: &str) -> Vec<ContainerInfo> {
52    let mut list = Vec::new();
53    for line in text.lines() {
54        let parts: Vec<&str> = line.split('\t').collect();
55        if parts.len() >= 4 {
56            let names = parts[1].to_string();
57            let project = parts.get(5).map_or("", |s| s.trim());
58            let stack = resolve_stack(&names, project);
59            list.push(ContainerInfo {
60                id: parts[0].to_string(),
61                names,
62                image: parts[2].to_string(),
63                status: parts[3].to_string(),
64                ports: parts.get(4).unwrap_or(&"").to_string(),
65                stack,
66                description: None,
67            });
68        }
69    }
70    list
71}
72
73/// Dockge stack id: compose project label, else primary container name.
74#[must_use]
75pub fn resolve_stack(names: &str, compose_project: &str) -> Option<String> {
76    let project = compose_project.trim();
77    if !project.is_empty() {
78        return Some(project.to_owned());
79    }
80    names
81        .split(',')
82        .map(str::trim)
83        .map(|n| n.trim_start_matches('/'))
84        .find(|n| !n.is_empty())
85        .map(str::to_owned)
86}
87
88/// List running containers, or an empty list when docker is missing / fails.
89///
90/// # Errors
91///
92/// Currently always returns `Ok`; IO failures degrade to an empty list.
93pub fn list_containers() -> Result<Vec<ContainerInfo>> {
94    if !docker_available() {
95        return Ok(Vec::new());
96    }
97    let output = Command::new("docker")
98        .args([
99            "ps",
100            "--format",
101            "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}\t{{.Label \"com.docker.compose.project\"}}",
102        ])
103        .output();
104    let Ok(output) = output else {
105        return Ok(Vec::new());
106    };
107    if !output.status.success() {
108        return Ok(Vec::new());
109    }
110    let text = String::from_utf8_lossy(&output.stdout);
111    Ok(parse_docker_ps_lines(&text))
112}
113
114/// Run `docker compose up -d` in `dir`.
115///
116/// # Errors
117///
118/// Returns [`HortoError::Message`] when docker is missing, or [`HortoError::CommandFailed`]
119/// when compose fails.
120pub fn compose_up(dir: &std::path::Path) -> Result<()> {
121    if !docker_available() {
122        return Err(HortoError::msg("docker not found on PATH"));
123    }
124    let status = Command::new("docker")
125        .args(["compose", "up", "-d"])
126        .current_dir(dir)
127        .status()
128        .map_err(|e| HortoError::command("docker", format!("compose up: {e}")))?;
129    if !status.success() {
130        return Err(HortoError::command("docker", "compose up -d failed"));
131    }
132    Ok(())
133}
134
135/// Rebuild and recreate one compose project in `dir` (`build --no-cache` then `up -d`).
136///
137/// # Errors
138///
139/// Returns [`HortoError::Message`] when docker is missing, or [`HortoError::CommandFailed`]
140/// when compose fails.
141pub fn docker_rebuild(dir: &std::path::Path) -> Result<()> {
142    if !docker_available() {
143        return Err(HortoError::msg("docker not found on PATH"));
144    }
145    let status = Command::new("docker")
146        .args(["compose", "build", "--no-cache"])
147        .current_dir(dir)
148        .status()
149        .map_err(|e| HortoError::command("docker", format!("compose build: {e}")))?;
150    if !status.success() {
151        return Err(HortoError::command(
152            "docker",
153            "compose build --no-cache failed",
154        ));
155    }
156    compose_up(dir)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn parse_skips_short_lines() {
165        assert_eq!(parse_docker_ps_lines("only\ttwo\n"), Vec::new());
166        let rows = parse_docker_ps_lines("id\tname\timg\tup\n");
167        assert_eq!(rows[0].ports, "");
168        assert_eq!(rows[0].stack.as_deref(), Some("name"));
169    }
170
171    #[test]
172    fn resolve_prefers_compose_project() {
173        assert_eq!(
174            resolve_stack("web", "homepage").as_deref(),
175            Some("homepage")
176        );
177        assert_eq!(
178            resolve_stack("/piper,/piper-1", "").as_deref(),
179            Some("piper")
180        );
181    }
182
183    #[test]
184    fn resolve_stack_none_when_no_source() {
185        assert_eq!(resolve_stack("", ""), None);
186        assert_eq!(resolve_stack(",,,", "").as_deref(), None);
187    }
188
189    #[test]
190    fn resolve_stack_trims_whitespace_project() {
191        assert_eq!(
192            resolve_stack("web", "  homepage  ").as_deref(),
193            Some("homepage")
194        );
195    }
196
197    #[test]
198    fn parse_docker_ps_multiple_rows_and_project_column() {
199        let text = "id1\ta\tnginx\tUp\t80/tcp\thomepage\nid2\tb\tredis\tUp\t\n";
200        let rows = parse_docker_ps_lines(text);
201        assert_eq!(rows.len(), 2);
202        assert_eq!(rows[0].stack.as_deref(), Some("homepage"));
203        // Empty project falls back to container name.
204        assert_eq!(rows[1].stack.as_deref(), Some("b"));
205        assert_eq!(rows[1].ports, "");
206    }
207
208    #[test]
209    fn parse_docker_ps_skips_headers_and_blank() {
210        assert_eq!(parse_docker_ps_lines(""), Vec::new());
211        assert_eq!(parse_docker_ps_lines("\n\n"), Vec::new());
212    }
213
214    #[test]
215    fn docker_available_returns_bool() {
216        // Just exercise the code path; the value depends on host PATH.
217        let _ = docker_available();
218    }
219
220    #[test]
221    fn compose_up_errors_when_docker_missing() {
222        if docker_available() {
223            return;
224        }
225        let err = compose_up(std::path::Path::new(".")).unwrap_err();
226        assert!(err.to_string().contains("docker not found"));
227    }
228
229    #[test]
230    fn compose_up_fails_without_compose_file_when_docker_present() {
231        if !docker_available() {
232            return;
233        }
234        let tmp = tempfile::TempDir::new().unwrap();
235        let err = compose_up(tmp.path()).unwrap_err();
236        assert!(err.to_string().contains("compose"));
237    }
238
239    #[test]
240    fn docker_rebuild_errors_when_docker_missing() {
241        if docker_available() {
242            return;
243        }
244        let err = docker_rebuild(std::path::Path::new(".")).unwrap_err();
245        assert!(err.to_string().contains("docker not found"));
246    }
247
248    #[test]
249    fn list_containers_never_fails_on_absent_daemon() {
250        // Regardless of whether docker is installed, list_containers is Ok.
251        let list = list_containers().unwrap();
252        // We cannot assert length; on a dev host with docker running it may be non-empty.
253        // Any Vec<ContainerInfo> is fine.
254        let _ = list;
255    }
256
257    #[test]
258    fn container_info_serializes_stack_and_description() {
259        let row = ContainerInfo {
260            id: "abc".into(),
261            names: "web".into(),
262            image: "nginx".into(),
263            status: "Up".into(),
264            ports: "80/tcp".into(),
265            stack: Some("homepage".into()),
266            description: Some("blurb".into()),
267        };
268        let json = serde_json::to_string(&row).unwrap();
269        assert!(json.contains("\"stack\":\"homepage\""));
270        assert!(json.contains("\"description\":\"blurb\""));
271        let back: ContainerInfo = serde_json::from_str(&json).unwrap();
272        assert_eq!(back, row);
273    }
274
275    #[test]
276    fn container_info_omits_optional_fields_when_none() {
277        let row = ContainerInfo {
278            id: "abc".into(),
279            names: "solo".into(),
280            image: "img".into(),
281            status: "Up".into(),
282            ports: String::new(),
283            stack: None,
284            description: None,
285        };
286        let json = serde_json::to_string(&row).unwrap();
287        assert!(!json.contains("\"stack\""));
288        assert!(!json.contains("\"description\""));
289    }
290
291    #[test]
292    fn docker_rebuild_returns_error_without_valid_compose() {
293        // Missing docker or empty compose dir both error; always exercise the call.
294        let tmp = tempfile::TempDir::new().unwrap();
295        let err = docker_rebuild(tmp.path()).unwrap_err();
296        let msg = err.to_string().to_ascii_lowercase();
297        let has_docker = msg.contains("docker");
298        let has_compose = msg.contains("compose");
299        assert!(has_docker | has_compose, "unexpected error: {msg}");
300    }
301}