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 quadlet = crate::quadlet_dir()?;
155    // Validated join: rejects path-like names so an absolute `name`
156    // can't replace the data root and make this enumerate (and a later
157    // purge delete) an arbitrary directory.
158    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
200/// Walk `path` recursively, summing file sizes. Does not follow symlinks.
201/// Returns 0 if the path does not exist.
202pub 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    // Caller passed a symlink root — don't follow it.
214    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
246/// Sum of all data paths + all volume mountpoints for a service.
247pub 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        // A dangling symlink inside the dir — must not cause a read error
286        // and must not be counted.
287        #[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}