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