1use std::collections::BTreeSet;
2use std::process::Command;
3
4use anyhow::Result;
5use serde::Serialize;
6
7use crate::codegen::distros::DistroFamily;
8use crate::config::Config;
9use crate::config::PackageManager;
10
11#[derive(Debug, Clone, Serialize)]
12pub struct DiffResult {
13 pub config_install: Vec<String>,
15 pub container_packages: Vec<String>,
18 pub missing: Vec<String>,
20 pub unexpected: Vec<String>,
23 pub has_drift: bool,
25}
26
27pub fn compute(config: &Config, name: &str, username: &str) -> Result<DiffResult> {
33 let manager = resolve_manager(config);
34 let raw = query_packages(name, username, manager)?;
35 let container_packages = parse_package_list(&raw, manager);
36
37 let config_set: BTreeSet<String> = config.image.packages.install.iter().cloned().collect();
38 let container_set: BTreeSet<String> = container_packages.iter().cloned().collect();
39
40 let missing: Vec<String> = config_set.difference(&container_set).cloned().collect();
41
42 let unexpected = if config.image.source().is_prebuilt() || config.image.base.contains('/') {
47 vec![]
48 } else {
49 compute_unexpected(&container_set, &config_set, manager)
50 };
51
52 let has_drift = !missing.is_empty() || !unexpected.is_empty();
53
54 Ok(DiffResult {
55 config_install: config.image.packages.install.clone(),
56 container_packages,
57 missing,
58 unexpected,
59 has_drift,
60 })
61}
62
63fn resolve_manager(config: &Config) -> PackageManager {
65 if config.image.packages.manager == PackageManager::Dnf
66 || config.image.packages.manager == PackageManager::Apt
67 || config.image.packages.manager == PackageManager::Pacman
68 || config.image.packages.manager == PackageManager::Apk
69 {
70 config.image.packages.manager
71 } else {
72 DistroFamily::from_base_image(&config.image.base).manager()
73 }
74 }
77
78fn query_cmd(manager: PackageManager) -> (&'static str, &'static [&'static str]) {
81 match manager {
82 PackageManager::Apt => ("dpkg-query", &["-W", "-f", "${Package}\n"]),
83 PackageManager::Pacman => ("pacman", &["-Qqn"]),
84 PackageManager::Apk => ("apk", &["list", "-I"]),
85 PackageManager::Dnf | PackageManager::Zypper => {
87 ("rpm", &["-qa", "--queryformat", "%{NAME}\n"])
88 }
89 }
90}
91
92fn query_packages(name: &str, username: &str, manager: PackageManager) -> Result<String> {
93 let (cmd, args) = query_cmd(manager);
94 let output = Command::new("podman")
95 .arg("exec")
96 .arg("-u")
97 .arg(username)
98 .arg(name)
99 .arg(cmd)
100 .args(args)
101 .output()?;
102
103 if !output.status.success() {
104 let stderr = String::from_utf8_lossy(&output.stderr);
105 anyhow::bail!("podman exec {} {} failed: {}", name, cmd, stderr.trim());
106 }
107
108 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
109}
110
111fn parse_package_list(raw: &str, manager: PackageManager) -> Vec<String> {
114 let mut pkgs: Vec<String> = raw
115 .lines()
116 .map(str::trim)
117 .filter(|l| !l.is_empty())
118 .filter_map(|l| normalize_package_name(l, manager))
119 .collect();
120 pkgs.sort();
121 pkgs.dedup();
122 pkgs
123}
124
125fn normalize_package_name(line: &str, manager: PackageManager) -> Option<String> {
128 match manager {
129 PackageManager::Apk => {
131 if let Some(start) = line.find('{') {
132 let end = line.find('}')?;
133 let name = line[start + 1..end].trim();
134 if !name.is_empty() {
135 return Some(name.to_string());
136 }
137 }
138 let first = line.split_whitespace().next()?;
139 let name = first.rsplit_once('-').map(|(n, _)| n).unwrap_or(first);
140 Some(name.to_string())
141 }
142 _ => {
144 let name = line.trim();
145 if name.is_empty() {
146 None
147 } else {
148 Some(name.to_string())
149 }
150 }
151 }
152}
153
154fn compute_unexpected(
159 container_set: &BTreeSet<String>,
160 config_set: &BTreeSet<String>,
161 manager: PackageManager,
162) -> Vec<String> {
163 let distro = match manager {
164 PackageManager::Apt => DistroFamily::DebianLike,
165 PackageManager::Pacman => DistroFamily::ArchLike,
166 PackageManager::Apk => DistroFamily::AlpineLike,
167 PackageManager::Dnf | PackageManager::Zypper => DistroFamily::FedoraLike,
168 };
169 let base_set: BTreeSet<String> = distro.base_packages(None).into_iter().collect();
170
171 container_set
172 .difference(config_set)
173 .filter(|pkg| !base_set.contains(pkg.as_str()))
174 .cloned()
175 .collect()
176}
177
178pub fn format_report(result: &DiffResult) -> String {
180 let mut lines = Vec::new();
181
182 if !result.has_drift {
183 lines.push("✓ All declared packages are installed — no drift detected.".to_string());
184 return lines.join("\n");
185 }
186
187 if !result.missing.is_empty() {
188 lines.push("── Declared packages NOT installed ──".to_string());
189 for pkg in &result.missing {
190 lines.push(format!(" - {pkg}"));
191 }
192 lines.push(String::new());
193 }
194
195 if !result.unexpected.is_empty() {
196 lines.push("── Unexpected packages found ──".to_string());
197 let show: Vec<&String> = result.unexpected.iter().take(30).collect();
198 for pkg in &show {
199 lines.push(format!(" + {pkg}"));
200 }
201 if result.unexpected.len() > 30 {
202 lines.push(format!(" … and {} more", result.unexpected.len() - 30));
203 }
204 lines.push(String::new());
205 }
206
207 lines.push(format!(
208 "{} missing, {} unexpected — drift detected.",
209 result.missing.len(),
210 result.unexpected.len(),
211 ));
212
213 lines.join("\n")
214}
215
216pub fn patch_toml(original: &str, install: &[String]) -> Result<String> {
223 let mut doc = original
224 .parse::<toml_edit::DocumentMut>()
225 .map_err(|e| anyhow::anyhow!("Failed to parse TOML for editing: {e}"))?;
226
227 let mut arr = toml_edit::Array::new();
228 for pkg in install {
229 arr.push(pkg);
230 }
231
232 doc["image"]["packages"]["install"] = toml_edit::value(arr);
233 Ok(doc.to_string())
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
243 fn parse_rpm_output() {
244 let raw = "bash\ncoreutils\nsudo\nzlib\nbash\n";
245 let pkgs = parse_package_list(raw, PackageManager::Dnf);
246 assert_eq!(pkgs, vec!["bash", "coreutils", "sudo", "zlib"]);
247 }
248
249 #[test]
250 fn parse_dpkg_output() {
251 let raw = "bash\ncoreutils\nsudo\nzlib\n";
252 let pkgs = parse_package_list(raw, PackageManager::Apt);
253 assert_eq!(pkgs, vec!["bash", "coreutils", "sudo", "zlib"]);
254 }
255
256 #[test]
257 fn parse_pacman_output() {
258 let raw = "bash\ncoreutils\nsudo\nzlib\n";
259 let pkgs = parse_package_list(raw, PackageManager::Pacman);
260 assert_eq!(pkgs, vec!["bash", "coreutils", "sudo", "zlib"]);
261 }
262
263 #[test]
264 fn parse_apk_output() {
265 let raw = "zlib-1.3.1-r0 x86_64 {zlib}\nalpine-base-3.20.0 x86_64 {alpine-base}\n";
266 let pkgs = parse_package_list(raw, PackageManager::Apk);
267 assert_eq!(pkgs, vec!["alpine-base", "zlib"]);
268 }
269
270 #[test]
271 fn parse_empty_output() {
272 let pkgs = parse_package_list("", PackageManager::Dnf);
273 assert!(pkgs.is_empty());
274 }
275
276 #[test]
277 fn parse_whitespace_only() {
278 let pkgs = parse_package_list(" \n \n", PackageManager::Dnf);
279 assert!(pkgs.is_empty());
280 }
281
282 #[test]
285 fn unexpected_excludes_base_packages() {
286 let container: BTreeSet<String> = ["sudo", "curl", "bash", "unrecognized-tool"]
287 .iter()
288 .map(|s| s.to_string())
289 .collect();
290 let config: BTreeSet<String> = ["bash"].iter().map(|s| s.to_string()).collect();
291 let unexpected = compute_unexpected(&container, &config, PackageManager::Dnf);
294 assert_eq!(unexpected, vec!["unrecognized-tool"]);
295 }
296
297 #[test]
300 fn report_no_drift() {
301 let result = DiffResult {
302 config_install: vec!["git".into()],
303 container_packages: vec!["git".into()],
304 missing: vec![],
305 unexpected: vec![],
306 has_drift: false,
307 };
308 let report = format_report(&result);
309 assert!(report.contains("no drift detected"));
310 }
311
312 #[test]
313 fn report_with_missing() {
314 let result = DiffResult {
315 config_install: vec!["git".into(), "htop".into()],
316 container_packages: vec!["git".into()],
317 missing: vec!["htop".into()],
318 unexpected: vec![],
319 has_drift: true,
320 };
321 let report = format_report(&result);
322 assert!(report.contains("htop"));
323 assert!(report.contains("missing"));
324 }
325
326 #[test]
329 fn patch_toml_replaces_existing_install() {
330 let original = r#"[image]
331base = "fedora:41"
332name = "myenv"
333
334[image.packages]
335install = ["git", "gcc"]
336remove = []
337"#;
338 let patched = patch_toml(original, &["git".into(), "htop".into()]).unwrap();
339 assert!(patched.contains(r#"install = ["git", "htop"]"#));
340 assert_eq!(patched.matches("install =").count(), 1);
342 }
343
344 #[test]
345 fn patch_toml_adds_install_when_missing() {
346 let original = r#"[image]
347base = "fedora:41"
348name = "myenv"
349
350[image.packages]
351remove = []
352"#;
353 let patched = patch_toml(original, &["git".into()]).unwrap();
354 assert!(patched.contains(r#"install = ["git"]"#));
355 }
356
357 #[test]
358 fn patch_toml_creates_section_when_absent() {
359 let original = r#"[image]
360base = "fedora:41"
361name = "myenv"
362"#;
363 let patched = patch_toml(original, &["git".into()]).unwrap();
364 assert!(patched.contains(r#"install = ["git"]"#));
365 }
366
367 #[test]
368 fn patch_toml_empty_install() {
369 let original = r#"[image]
370base = "fedora:41"
371name = "myenv"
372
373[image.packages]
374install = ["git"]
375"#;
376 let patched = patch_toml(original, &[] as &[String]).unwrap();
377 assert!(patched.contains(r#"install = []"#));
378 }
379}