horto_os_ui_shared/kits/
docker.rs1use crate::error::{HortoError, Result};
6use serde::{Deserialize, Serialize};
7use std::process::Command;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct ContainerInfo {
12 pub id: String,
14 pub names: String,
16 pub image: String,
18 pub status: String,
20 pub ports: String,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub stack: Option<String>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub description: Option<String>,
28}
29
30#[must_use]
32pub fn docker_available() -> bool {
33 which::which("docker").is_ok()
34}
35
36#[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#[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
88pub 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
114pub 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
135pub 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 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 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 let list = list_containers().unwrap();
252 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 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}