Skip to main content

ryra_core/
metadata.rs

1//! Per-install record. Written at `ryra add` time, read by every command
2//! that needs to know how a service was set up. Mirrors the data that used
3//! to live in `# Service-*` quadlet header comments.
4
5use crate::capability::Capability;
6use crate::error::{Error, Result};
7use crate::paths::metadata_path;
8use crate::registry::service_def::AuthKind;
9
10/// Per-install record persisted to `~/.local/share/services/<name>/metadata.toml`.
11///
12/// Exposure isn't stored — it's derived from `url` at read time
13/// (absent = Loopback, `.internal` = Internal, `.ts.net` = Tailscale,
14/// otherwise Public). One source of truth for "where does this
15/// service live."
16#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
17pub struct Metadata {
18    pub registry: String,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub url: Option<String>,
21    /// Auth kind: `oidc` if `--auth` was used, otherwise absent.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub auth: Option<AuthKind>,
24    /// Capabilities the service provides — snapshotted from
25    /// `service.toml` at install time so [`crate::list_installed`] can
26    /// answer "is there an installed reverse proxy / OIDC provider /
27    /// SMTP relay / metrics scraper?" without re-reading the registry.
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub provides: Vec<Capability>,
30    /// True if `--backup` was passed at `ryra add` time. Drives
31    /// whether `ryra backup run` picks this install up.
32    ///
33    /// Default `false` so an existing install (written by a ryra
34    /// version that pre-dates the backup feature) reads back as
35    /// not-enabled rather than as malformed.
36    #[serde(default, skip_serializing_if = "is_false")]
37    pub backup_enabled: bool,
38    /// Whether the user opted in to global-SMTP wiring for this install
39    /// (the `--smtp` flag at install time, or "yes" at the interactive
40    /// SMTP prompt). Stored as *user intent*, NOT as "SMTP is currently
41    /// being rendered" — the latter is gated additionally on
42    /// `config.smtp.is_some()` inside the planner. Decoupling lets
43    /// `ryra configure` remember the choice across re-renders even when
44    /// global SMTP isn't configured yet.
45    ///
46    /// Default `true` so installs that pre-date this field read back
47    /// as opt-in (matches the historical CLI shape: `ryra add` passed
48    /// `enable_smtp = true` unconditionally and let the planner gate).
49    #[serde(default = "default_true", skip_serializing_if = "is_true")]
50    pub smtp_enabled: bool,
51    /// `[[env_group]]` bundles that were enabled at install time.
52    /// Persisted so `ryra configure --disable <group>` and re-renders
53    /// know which group members belong in the rendered `.env`. Default
54    /// empty for legacy installs (groups are an opt-in feature; an
55    /// empty list reads back as "no groups were toggled").
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub enabled_groups: Vec<String>,
58}
59
60fn is_false(b: &bool) -> bool {
61    !b
62}
63
64fn is_true(b: &bool) -> bool {
65    *b
66}
67
68fn default_true() -> bool {
69    true
70}
71
72/// Load metadata.toml for an installed service. Returns `None` if the
73/// file doesn't exist (service not installed via this ryra version, or
74/// uninstalled), `Err` if it exists but can't be parsed.
75pub fn load_metadata(service_name: &str) -> Result<Option<Metadata>> {
76    let path = metadata_path(service_name)?;
77    if !path.exists() {
78        return Ok(None);
79    }
80    let content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
81        path: path.clone(),
82        source,
83    })?;
84    let meta: Metadata = toml::from_str(&content).map_err(|source| Error::TomlParse {
85        path: path.clone(),
86        source,
87    })?;
88    Ok(Some(meta))
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn backup_enabled_defaults_false_on_legacy_metadata() {
97        // Pre-feature metadata files have no `backup_enabled` key.
98        let toml_src = r#"
99registry = "bundled"
100"#;
101        let meta: Metadata = toml::from_str(toml_src).expect("parse");
102        assert!(!meta.backup_enabled);
103    }
104
105    #[test]
106    fn backup_enabled_round_trips() {
107        let meta = Metadata {
108            registry: "bundled".into(),
109            url: None,
110            auth: None,
111            provides: vec![],
112            backup_enabled: true,
113            smtp_enabled: true,
114            enabled_groups: vec![],
115        };
116        let text = toml::to_string(&meta).expect("serialize");
117        assert!(
118            text.contains("backup_enabled = true"),
119            "serialized form: {text}"
120        );
121        let parsed: Metadata = toml::from_str(&text).expect("parse");
122        assert!(parsed.backup_enabled);
123    }
124
125    #[test]
126    fn backup_enabled_false_is_omitted_from_serialization() {
127        // Reduce visual noise for the common case (every existing
128        // service today): when off, the field shouldn't appear at all.
129        let meta = Metadata {
130            registry: "bundled".into(),
131            url: None,
132            auth: None,
133            provides: vec![],
134            backup_enabled: false,
135            smtp_enabled: true,
136            enabled_groups: vec![],
137        };
138        let text = toml::to_string(&meta).expect("serialize");
139        assert!(!text.contains("backup_enabled"), "got: {text}");
140    }
141}