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 home_root = crate::service_data_root()?;
155 let quadlet = crate::quadlet_dir()?;
156 let home_dir = home_root.join(name);
157 let known = [name.to_string()];
158
159 let quadlet_vols = volumes::parse_volume_quadlets(&quadlet, &known)?;
160 let podman_vols = volumes::list_podman_volumes();
161 let mut all_vols = volumes::reconcile(quadlet_vols, podman_vols);
162 for vr in &mut all_vols {
163 if vr.owner.is_none() {
164 let stem = vr.name.strip_prefix("systemd-").unwrap_or(&vr.name);
165 vr.owner = volumes::match_owner(stem, &known);
166 }
167 }
168 let svc_vols: Vec<volumes::VolumeRef> = all_vols
169 .into_iter()
170 .filter(|v| v.owner.as_deref() == Some(name))
171 .collect();
172
173 let data_paths = if home_dir.exists() {
174 classify::classify_home_dir(&home_dir)?.0
175 } else {
176 Vec::new()
177 };
178
179 if !home_dir.exists() && svc_vols.is_empty() {
180 return Ok(None);
181 }
182
183 let status = if crate::is_service_installed(name) {
184 ServiceStatus::Installed
185 } else {
186 ServiceStatus::Orphan
187 };
188
189 Ok(Some(ServiceData {
190 service: name.to_string(),
191 status,
192 home_dir,
193 data_paths,
194 volumes: svc_vols,
195 }))
196}
197
198pub fn dir_size_bytes(path: &Path) -> Result<u64> {
201 let root_meta = match std::fs::symlink_metadata(path) {
202 Ok(m) => m,
203 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
204 Err(source) => {
205 return Err(Error::FileRead {
206 path: path.to_path_buf(),
207 source,
208 });
209 }
210 };
211 if root_meta.file_type().is_symlink() {
213 return Ok(0);
214 }
215 let mut total = 0u64;
216 let mut stack = vec![path.to_path_buf()];
217 while let Some(p) = stack.pop() {
218 let meta = std::fs::symlink_metadata(&p).map_err(|source| Error::FileRead {
219 path: p.clone(),
220 source,
221 })?;
222 if meta.file_type().is_symlink() {
223 continue;
224 }
225 if meta.is_file() {
226 total += meta.len();
227 } else if meta.is_dir() {
228 let entries = std::fs::read_dir(&p).map_err(|source| Error::FileRead {
229 path: p.clone(),
230 source,
231 })?;
232 for entry in entries {
233 let entry = entry.map_err(|source| Error::FileRead {
234 path: p.clone(),
235 source,
236 })?;
237 stack.push(entry.path());
238 }
239 }
240 }
241 Ok(total)
242}
243
244pub fn size_bytes(data: &ServiceData) -> Result<u64> {
246 let mut total = 0;
247 for p in &data.data_paths {
248 total += dir_size_bytes(p)?;
249 }
250 for v in &data.volumes {
251 if let Some(mp) = volumes::mountpoint_of(&v.name) {
252 total += dir_size_bytes(&mp)?;
253 }
254 }
255 Ok(total)
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn dir_size_sums_file_sizes() {
264 let dir = tempfile::tempdir().unwrap();
265 std::fs::write(dir.path().join("a.bin"), vec![0u8; 100]).unwrap();
266 std::fs::create_dir(dir.path().join("sub")).unwrap();
267 std::fs::write(dir.path().join("sub/b.bin"), vec![0u8; 250]).unwrap();
268 assert_eq!(dir_size_bytes(dir.path()).unwrap(), 350);
269 }
270
271 #[test]
272 fn dir_size_missing_path_is_zero() {
273 assert_eq!(
274 dir_size_bytes(std::path::Path::new("/nonexistent-xyz-789")).unwrap(),
275 0
276 );
277 }
278
279 #[test]
280 fn dir_size_skips_symlinks() {
281 let dir = tempfile::tempdir().unwrap();
282 std::fs::write(dir.path().join("real.bin"), vec![0u8; 200]).unwrap();
283 #[cfg(unix)]
286 std::os::unix::fs::symlink("/nonexistent-target", dir.path().join("link")).unwrap();
287 assert_eq!(dir_size_bytes(dir.path()).unwrap(), 200);
288 }
289}