1pub mod classify;
5pub mod volumes;
6
7use std::path::{Path, PathBuf};
8
9use crate::error::{Error, Result};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ServiceStatus {
13 Installed,
15 Orphan,
17}
18
19#[derive(Debug, Clone)]
20pub struct ServiceData {
21 pub service: String,
22 pub status: ServiceStatus,
23 pub home_dir: PathBuf,
25 pub data_paths: Vec<PathBuf>,
27 pub volumes: Vec<volumes::VolumeRef>,
28}
29
30const NON_SERVICE_DIRS: &[&str] = &["test-reports"];
34
35pub fn enumerate_all() -> Result<Vec<ServiceData>> {
37 let home_root = crate::service_data_root()?;
38 let quadlet = crate::quadlet_dir()?;
39
40 let managed_via_marker: std::collections::HashSet<String> = crate::scan_managed_services()
46 .unwrap_or_default()
47 .into_iter()
48 .collect();
49 let mut names: std::collections::BTreeSet<String> =
50 managed_via_marker.iter().cloned().collect();
51 if home_root.is_dir() {
52 let entries = std::fs::read_dir(&home_root).map_err(|source| Error::FileRead {
53 path: home_root.clone(),
54 source,
55 })?;
56 for entry in entries {
57 let entry = entry.map_err(|source| Error::FileRead {
58 path: home_root.clone(),
59 source,
60 })?;
61 if entry
62 .file_type()
63 .map_err(|source| Error::FileRead {
64 path: entry.path(),
65 source,
66 })?
67 .is_dir()
68 && let Some(n) = entry.file_name().to_str()
69 && !NON_SERVICE_DIRS.contains(&n)
70 {
71 names.insert(n.to_string());
72 }
73 }
74 }
75
76 let known: Vec<String> = names.iter().cloned().collect();
77 let quadlet_vols = volumes::parse_volume_quadlets(&quadlet, &known)?;
78 let podman_vols = volumes::list_podman_volumes();
79 let mut all_vols = volumes::reconcile(quadlet_vols, podman_vols);
80 for vr in &mut all_vols {
82 if vr.owner.is_none() {
83 let stem = vr.name.strip_prefix("systemd-").unwrap_or(&vr.name);
84 vr.owner = volumes::match_owner(stem, &known);
85 }
86 }
87
88 for vr in &all_vols {
90 if let Some(owner) = &vr.owner {
91 names.insert(owner.clone());
92 }
93 }
94
95 let mut out = Vec::with_capacity(names.len());
96 for name in names {
97 let status = if managed_via_marker.contains(&name) {
101 ServiceStatus::Installed
102 } else {
103 ServiceStatus::Orphan
104 };
105 let home_dir = home_root.join(&name);
106 let data_paths = if home_dir.exists() {
107 classify::classify_home_dir(&home_dir)?.0
108 } else {
109 Vec::new()
110 };
111 let svc_vols: Vec<volumes::VolumeRef> = all_vols
112 .iter()
113 .filter(|v| v.owner.as_deref() == Some(name.as_str()))
114 .cloned()
115 .collect();
116 if !home_dir.exists() && svc_vols.is_empty() {
119 continue;
120 }
121 out.push(ServiceData {
122 service: name,
123 status,
124 home_dir,
125 data_paths,
126 volumes: svc_vols,
127 });
128 }
129 Ok(out)
130}
131
132pub fn enumerate_service(name: &str) -> Result<Option<ServiceData>> {
142 let home_root = crate::service_data_root()?;
143 let quadlet = crate::quadlet_dir()?;
144 let home_dir = home_root.join(name);
145 let known = [name.to_string()];
146
147 let quadlet_vols = volumes::parse_volume_quadlets(&quadlet, &known)?;
148 let podman_vols = volumes::list_podman_volumes();
149 let mut all_vols = volumes::reconcile(quadlet_vols, podman_vols);
150 for vr in &mut all_vols {
151 if vr.owner.is_none() {
152 let stem = vr.name.strip_prefix("systemd-").unwrap_or(&vr.name);
153 vr.owner = volumes::match_owner(stem, &known);
154 }
155 }
156 let svc_vols: Vec<volumes::VolumeRef> = all_vols
157 .into_iter()
158 .filter(|v| v.owner.as_deref() == Some(name))
159 .collect();
160
161 let data_paths = if home_dir.exists() {
162 classify::classify_home_dir(&home_dir)?.0
163 } else {
164 Vec::new()
165 };
166
167 if !home_dir.exists() && svc_vols.is_empty() {
168 return Ok(None);
169 }
170
171 let status = if crate::is_service_installed(name) {
172 ServiceStatus::Installed
173 } else {
174 ServiceStatus::Orphan
175 };
176
177 Ok(Some(ServiceData {
178 service: name.to_string(),
179 status,
180 home_dir,
181 data_paths,
182 volumes: svc_vols,
183 }))
184}
185
186pub fn dir_size_bytes(path: &Path) -> Result<u64> {
189 let root_meta = match std::fs::symlink_metadata(path) {
190 Ok(m) => m,
191 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
192 Err(source) => {
193 return Err(Error::FileRead {
194 path: path.to_path_buf(),
195 source,
196 });
197 }
198 };
199 if root_meta.file_type().is_symlink() {
201 return Ok(0);
202 }
203 let mut total = 0u64;
204 let mut stack = vec![path.to_path_buf()];
205 while let Some(p) = stack.pop() {
206 let meta = std::fs::symlink_metadata(&p).map_err(|source| Error::FileRead {
207 path: p.clone(),
208 source,
209 })?;
210 if meta.file_type().is_symlink() {
211 continue;
212 }
213 if meta.is_file() {
214 total += meta.len();
215 } else if meta.is_dir() {
216 let entries = std::fs::read_dir(&p).map_err(|source| Error::FileRead {
217 path: p.clone(),
218 source,
219 })?;
220 for entry in entries {
221 let entry = entry.map_err(|source| Error::FileRead {
222 path: p.clone(),
223 source,
224 })?;
225 stack.push(entry.path());
226 }
227 }
228 }
229 Ok(total)
230}
231
232pub fn size_bytes(data: &ServiceData) -> Result<u64> {
234 let mut total = 0;
235 for p in &data.data_paths {
236 total += dir_size_bytes(p)?;
237 }
238 for v in &data.volumes {
239 if let Some(mp) = volumes::mountpoint_of(&v.name) {
240 total += dir_size_bytes(&mp)?;
241 }
242 }
243 Ok(total)
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn dir_size_sums_file_sizes() {
252 let dir = tempfile::tempdir().unwrap();
253 std::fs::write(dir.path().join("a.bin"), vec![0u8; 100]).unwrap();
254 std::fs::create_dir(dir.path().join("sub")).unwrap();
255 std::fs::write(dir.path().join("sub/b.bin"), vec![0u8; 250]).unwrap();
256 assert_eq!(dir_size_bytes(dir.path()).unwrap(), 350);
257 }
258
259 #[test]
260 fn dir_size_missing_path_is_zero() {
261 assert_eq!(
262 dir_size_bytes(std::path::Path::new("/nonexistent-xyz-789")).unwrap(),
263 0
264 );
265 }
266
267 #[test]
268 fn dir_size_skips_symlinks() {
269 let dir = tempfile::tempdir().unwrap();
270 std::fs::write(dir.path().join("real.bin"), vec![0u8; 200]).unwrap();
271 #[cfg(unix)]
274 std::os::unix::fs::symlink("/nonexistent-target", dir.path().join("link")).unwrap();
275 assert_eq!(dir_size_bytes(dir.path()).unwrap(), 200);
276 }
277}