Skip to main content

ryra_core/data/
mod.rs

1//! Per-service data enumeration for `ryra list` + the
2//! data-preserving variant of `ryra remove`.
3
4pub mod classify;
5pub mod volumes;
6
7use std::path::{Path, PathBuf};
8
9use crate::error::{Error, Result};
10use crate::registry::service_def::Runtime;
11
12/// Whether `name` is an installed native service: its install record says
13/// `runtime = native` and the systemd --user unit is present. Cheap (one
14/// metadata read + one `exists`), so it's fine to call per candidate.
15fn 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    /// Service is present in preferences.toml with installed=true.
25    Installed,
26    /// Service has data but preferences.toml has no entry (or installed=false).
27    Orphan,
28}
29
30#[derive(Debug, Clone)]
31pub struct ServiceData {
32    pub service: String,
33    pub status: ServiceStatus,
34    /// `~/.local/share/services/<service>/` — may not exist if only volumes remain.
35    pub home_dir: PathBuf,
36    /// Top-level children of `home_dir` classified as data (not ephemeral).
37    pub data_paths: Vec<PathBuf>,
38    pub volumes: Vec<volumes::VolumeRef>,
39}
40
41/// Top-level dirs under `~/.local/share/services/` that are NOT services —
42/// written by ryra itself for tooling (e.g. test reports). Skip them so
43/// `ryra data ls` doesn't surface them as orphan services.
44const NON_SERVICE_DIRS: &[&str] = &["test-reports"];
45
46/// Walk every ryra-visible service and return one `ServiceData` per service.
47pub fn enumerate_all() -> Result<Vec<ServiceData>> {
48    let home_root = crate::service_data_root()?;
49    let quadlet = crate::quadlet_dir()?;
50
51    // Candidate service names: every quadlet with our `# Service-Source:`
52    // marker + every dir under the data root. The marker scan is the
53    // authoritative source for "installed"; data-root dirs catch
54    // orphan data (services that were removed in Preserve mode and
55    // still have a home dir or volumes lying around).
56    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    // Owner inference for podman-only volumes (quadlet parse couldn't see them).
92    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    // Any volume with an owner that is NOT in names → add the owner as a service candidate.
100    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        // Installed = a podman service with our quadlet marker, OR a native
109        // service whose systemd --user unit is present. Marker/unit absent but
110        // home dir or volumes still around → orphan (typically left by a
111        // Preserve-mode `ryra remove`, awaiting `--purge`).
112        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        // Skip entries with no home dir AND no volumes (happens when a name
129        // slipped in but has nothing associated; shouldn't occur in practice).
130        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
144/// Look up a single service. Queries the filesystem + podman directly for
145/// the given name rather than walking every service via `enumerate_all`.
146///
147/// This matters for true orphans: after `ryra remove <svc>` in Preserve
148/// mode the config entry is gone AND the home dir is often deleted (empty
149/// once .env is wiped), so `enumerate_all`'s owner inference has no
150/// `known_services` hint to match `systemd-<svc>-data` against and those
151/// volumes end up unattributed. Looking up by name dodges that because
152/// the name itself seeds the owner match.
153pub 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
198/// Walk `path` recursively, summing file sizes. Does not follow symlinks.
199/// Returns 0 if the path does not exist.
200pub 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    // Caller passed a symlink root — don't follow it.
212    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
244/// Sum of all data paths + all volume mountpoints for a service.
245pub 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        // A dangling symlink inside the dir — must not cause a read error
284        // and must not be counted.
285        #[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}