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}