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};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ServiceStatus {
13    /// Service is present in preferences.toml with installed=true.
14    Installed,
15    /// Service has data but preferences.toml has no entry (or installed=false).
16    Orphan,
17}
18
19#[derive(Debug, Clone)]
20pub struct ServiceData {
21    pub service: String,
22    pub status: ServiceStatus,
23    /// `~/.local/share/services/<service>/` — may not exist if only volumes remain.
24    pub home_dir: PathBuf,
25    /// Top-level children of `home_dir` classified as data (not ephemeral).
26    pub data_paths: Vec<PathBuf>,
27    pub volumes: Vec<volumes::VolumeRef>,
28}
29
30/// Top-level dirs under `~/.local/share/services/` that are NOT services —
31/// written by ryra itself for tooling (e.g. test reports). Skip them so
32/// `ryra data ls` doesn't surface them as orphan services.
33const NON_SERVICE_DIRS: &[&str] = &["test-reports"];
34
35/// Walk every ryra-visible service and return one `ServiceData` per service.
36pub fn enumerate_all() -> Result<Vec<ServiceData>> {
37    let home_root = crate::service_data_root()?;
38    let quadlet = crate::quadlet_dir()?;
39
40    // Candidate service names: every quadlet with our `# Service-Source:`
41    // marker + every dir under the data root. The marker scan is the
42    // authoritative source for "installed"; data-root dirs catch
43    // orphan data (services that were removed in Preserve mode and
44    // still have a home dir or volumes lying around).
45    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    // Owner inference for podman-only volumes (quadlet parse couldn't see them).
81    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    // Any volume with an owner that is NOT in names → add the owner as a service candidate.
89    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        // Marker present → installed. Marker absent but home dir or
98        // volumes still around → orphan (typically left by a Preserve
99        // mode `ryra remove`, awaiting `--purge`).
100        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        // Skip entries with no home dir AND no volumes (happens when a name
117        // slipped in but has nothing associated; shouldn't occur in practice).
118        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
132/// Look up a single service. Queries the filesystem + podman directly for
133/// the given name rather than walking every service via `enumerate_all`.
134///
135/// This matters for true orphans: after `ryra remove <svc>` in Preserve
136/// mode the config entry is gone AND the home dir is often deleted (empty
137/// once .env is wiped), so `enumerate_all`'s owner inference has no
138/// `known_services` hint to match `systemd-<svc>-data` against and those
139/// volumes end up unattributed. Looking up by name dodges that because
140/// the name itself seeds the owner match.
141pub 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
186/// Walk `path` recursively, summing file sizes. Does not follow symlinks.
187/// Returns 0 if the path does not exist.
188pub 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    // Caller passed a symlink root — don't follow it.
200    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
232/// Sum of all data paths + all volume mountpoints for a service.
233pub 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        // A dangling symlink inside the dir — must not cause a read error
272        // and must not be counted.
273        #[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}