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}