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, Runtime};
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 /// How this service runs: a podman container (default) or a native binary
59 /// under systemd --user. Recorded at install time so post-install commands
60 /// (remove, list, status, backup) stay runtime-aware from the install
61 /// record alone, never depending on the registry (which may drift or be
62 /// gone). Absent in legacy installs reads back as `Podman`.
63 #[serde(default, skip_serializing_if = "Runtime::is_podman")]
64 pub runtime: Runtime,
65}
66
67fn is_false(b: &bool) -> bool {
68 !b
69}
70
71fn is_true(b: &bool) -> bool {
72 *b
73}
74
75fn default_true() -> bool {
76 true
77}
78
79/// Load metadata.toml for an installed service. Returns `None` if the
80/// file doesn't exist (service not installed via this ryra version, or
81/// uninstalled), `Err` if it exists but can't be parsed.
82pub fn load_metadata(service_name: &str) -> Result<Option<Metadata>> {
83 let path = metadata_path(service_name)?;
84 if !path.exists() {
85 return Ok(None);
86 }
87 let content = std::fs::read_to_string(&path).map_err(|source| Error::FileRead {
88 path: path.clone(),
89 source,
90 })?;
91 let meta: Metadata = toml::from_str(&content).map_err(|source| Error::TomlParse {
92 path: path.clone(),
93 source,
94 })?;
95 Ok(Some(meta))
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn backup_enabled_defaults_false_on_legacy_metadata() {
104 // Pre-feature metadata files have no `backup_enabled` key.
105 let toml_src = r#"
106registry = "default"
107"#;
108 let meta: Metadata = toml::from_str(toml_src).expect("parse");
109 assert!(!meta.backup_enabled);
110 }
111
112 #[test]
113 fn backup_enabled_round_trips() {
114 let meta = Metadata {
115 registry: "default".into(),
116 url: None,
117 auth: None,
118 provides: vec![],
119 backup_enabled: true,
120 smtp_enabled: true,
121 enabled_groups: vec![],
122 runtime: Default::default(),
123 };
124 let text = toml::to_string(&meta).expect("serialize");
125 assert!(
126 text.contains("backup_enabled = true"),
127 "serialized form: {text}"
128 );
129 let parsed: Metadata = toml::from_str(&text).expect("parse");
130 assert!(parsed.backup_enabled);
131 }
132
133 #[test]
134 fn backup_enabled_false_is_omitted_from_serialization() {
135 // Reduce visual noise for the common case (every existing
136 // service today): when off, the field shouldn't appear at all.
137 let meta = Metadata {
138 registry: "default".into(),
139 url: None,
140 auth: None,
141 provides: vec![],
142 backup_enabled: false,
143 smtp_enabled: true,
144 enabled_groups: vec![],
145 runtime: Default::default(),
146 };
147 let text = toml::to_string(&meta).expect("serialize");
148 assert!(!text.contains("backup_enabled"), "got: {text}");
149 }
150}