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