Skip to main content

solid_pod_rs_forge/
config_file.rs

1//! JSON configuration layering: explicit config over a `FORGE_CONFIG` file.
2//!
3//! Mirrors the JSS `forge` activation: when the `FORGE_CONFIG` environment
4//! variable names a JSON file its keys are merged in as *defaults* (an
5//! explicitly supplied key always wins; an unset variable or an unreadable
6//! file is a no-op that only logs). Keys use the JSS camelCase spelling
7//! (`privateRepos`, `baseUrl`, `loopbackUrl`, `cspConnect`, `pushTokenTtl`,
8//! `chain`, `allowMainnet`, `announceRelays`, `prefix`, `openEdit`).
9//!
10//! The loader takes an explicit path so tests never touch process
11//! environment; [`ForgeConfig::from_env`] is the thin `FORGE_CONFIG` wrapper.
12
13use std::path::Path;
14
15use serde_json::Value;
16
17use crate::config::ForgeConfig;
18use crate::error::ForgeError;
19
20/// Name of the environment variable that points at the defaults file.
21pub const FORGE_CONFIG_ENV: &str = "FORGE_CONFIG";
22
23/// Read and parse a JSON object from `path`.
24///
25/// # Errors
26///
27/// [`ForgeError::Io`] when unreadable, [`ForgeError::BadRequest`] when the
28/// file is not a JSON object.
29pub fn load_file(path: &Path) -> Result<Value, ForgeError> {
30    let raw = std::fs::read_to_string(path)?;
31    let v: Value = serde_json::from_str(&raw)?;
32    if v.is_object() {
33        Ok(v)
34    } else {
35        Err(ForgeError::BadRequest(
36            "config file is not a JSON object".into(),
37        ))
38    }
39}
40
41/// Copy every key of `file` that `explicit` does not define into `explicit`
42/// (JSS: "explicit `api.config` wins"). Non-object inputs are treated as
43/// empty objects.
44///
45/// # Examples
46///
47/// ```
48/// use serde_json::json;
49/// use solid_pod_rs_forge::config_file::merge_defaults;
50///
51/// let merged = merge_defaults(json!({"privateRepos": true}), json!({"privateRepos": false, "baseUrl": "https://f.example"}));
52/// assert_eq!(merged["privateRepos"], true);
53/// assert_eq!(merged["baseUrl"], "https://f.example");
54/// ```
55#[must_use]
56pub fn merge_defaults(explicit: Value, file: Value) -> Value {
57    let mut out = match explicit {
58        Value::Object(m) => m,
59        _ => serde_json::Map::new(),
60    };
61    if let Value::Object(f) = file {
62        for (k, v) in f {
63            out.entry(k).or_insert(v);
64        }
65    }
66    Value::Object(out)
67}
68
69fn str_list(v: &Value) -> Vec<String> {
70    v.as_array()
71        .map(|a| {
72            a.iter()
73                .filter_map(|x| x.as_str().map(str::to_string))
74                .collect()
75        })
76        .unwrap_or_default()
77}
78
79fn opt_url(v: &Value) -> Option<String> {
80    v.as_str()
81        .map(|s| s.trim_end_matches('/').to_string())
82        .filter(|s| !s.is_empty())
83}
84
85impl ForgeConfig {
86    /// Build a config from a JSON object in the JSS key spelling. Unknown
87    /// keys are ignored; absent keys keep their [`Default`].
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use serde_json::json;
93    /// use solid_pod_rs_forge::ForgeConfig;
94    ///
95    /// let c = ForgeConfig::from_json(&json!({"privateRepos": true, "baseUrl": "https://f.example/", "cspConnect": ["https://idp.example"]}));
96    /// assert!(c.private_repos);
97    /// assert_eq!(c.base_url.as_deref(), Some("https://f.example"));
98    /// assert_eq!(c.csp_connect, vec!["https://idp.example".to_string()]);
99    /// ```
100    #[must_use]
101    pub fn from_json(v: &Value) -> Self {
102        let mut c = Self::default();
103        if let Some(p) = v.get("prefix").and_then(Value::as_str) {
104            c.prefix = p.to_string();
105        }
106        if let Some(t) = v.get("pushTokenTtl").and_then(Value::as_u64) {
107            c.token_ttl_secs = t;
108        }
109        if let Some(b) = v.get("privateRepos").and_then(Value::as_bool) {
110            c.private_repos = b;
111        }
112        if let Some(ch) = v.get("chain").and_then(Value::as_str) {
113            c.default_chain = ch.to_string();
114        }
115        if let Some(b) = v.get("allowMainnet").and_then(Value::as_bool) {
116            c.allow_mainnet = b;
117        }
118        if let Some(x) = v.get("announceRelays") {
119            c.announce_relays = str_list(x);
120        }
121        if let Some(x) = v.get("baseUrl") {
122            c.base_url = opt_url(x);
123        }
124        if let Some(x) = v.get("loopbackUrl") {
125            c.loopback_url = opt_url(x);
126        }
127        if let Some(x) = v.get("cspConnect") {
128            c.csp_connect = str_list(x);
129        }
130        if let Some(b) = v.get("openEdit").and_then(Value::as_bool) {
131            c.open_edit = b;
132        }
133        c
134    }
135
136    /// Layer an optional defaults file under `explicit` and build the config.
137    /// A missing or unreadable file only logs a warning (JSS behaviour).
138    #[must_use]
139    pub fn from_layers(explicit: Value, defaults_file: Option<&Path>) -> Self {
140        let merged = match defaults_file {
141            Some(p) => match load_file(p) {
142                Ok(file) => merge_defaults(explicit, file),
143                Err(e) => {
144                    tracing::warn!("forge: {FORGE_CONFIG_ENV} load failed: {e}");
145                    explicit
146                }
147            },
148            None => explicit,
149        };
150        Self::from_json(&merged)
151    }
152
153    /// [`from_layers`](Self::from_layers) with the file taken from the
154    /// `FORGE_CONFIG` environment variable (unset or empty: no file).
155    #[must_use]
156    pub fn from_env(explicit: Value) -> Self {
157        let path = std::env::var_os(FORGE_CONFIG_ENV).filter(|p| !p.is_empty());
158        Self::from_layers(explicit, path.as_deref().map(Path::new))
159    }
160
161    /// The public origin: `base_url` when configured, else `fallback`
162    /// (typically the request's `host_url`), without a trailing slash.
163    #[must_use]
164    pub fn public_origin(&self, fallback: Option<&str>) -> String {
165        self.base_url
166            .clone()
167            .or_else(|| fallback.map(|f| f.trim_end_matches('/').to_string()))
168            .unwrap_or_default()
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use serde_json::json;
176
177    #[test]
178    fn explicit_wins_over_file_defaults() {
179        let td = tempfile::TempDir::new().unwrap();
180        let f = td.path().join("forge.json");
181        std::fs::write(
182            &f,
183            r#"{"privateRepos": true, "baseUrl": "https://f.example/", "cspConnect": ["https://idp.example", 7], "pushTokenTtl": 99}"#,
184        )
185        .unwrap();
186        let c = ForgeConfig::from_layers(json!({"privateRepos": false}), Some(&f));
187        assert!(!c.private_repos, "explicit key wins");
188        assert_eq!(c.base_url.as_deref(), Some("https://f.example"));
189        assert_eq!(c.csp_connect, vec!["https://idp.example".to_string()]);
190        assert_eq!(c.token_ttl_secs, 99);
191    }
192
193    #[test]
194    fn missing_or_bad_file_is_a_no_op() {
195        let td = tempfile::TempDir::new().unwrap();
196        let c = ForgeConfig::from_layers(
197            json!({"privateRepos": true}),
198            Some(&td.path().join("nope.json")),
199        );
200        assert!(c.private_repos);
201        let bad = td.path().join("bad.json");
202        std::fs::write(&bad, "[1,2]").unwrap();
203        let c = ForgeConfig::from_layers(json!({}), Some(&bad));
204        assert!(!c.private_repos);
205        std::fs::write(&bad, "not json").unwrap();
206        assert!(load_file(&bad).is_err());
207        let c = ForgeConfig::from_layers(json!({}), None);
208        assert_eq!(c.prefix, "/forge");
209    }
210
211    #[test]
212    fn keys_map_and_origin_resolution() {
213        let c = ForgeConfig::from_json(&json!({
214            "prefix": "/git", "chain": "signet", "allowMainnet": true,
215            "announceRelays": ["wss://r.example"], "loopbackUrl": "http://127.0.0.1:3000/",
216            "baseUrl": ""
217        }));
218        assert_eq!(c.prefix, "/git");
219        assert_eq!(c.default_chain, "signet");
220        assert!(c.allow_mainnet);
221        assert_eq!(c.announce_relays, vec!["wss://r.example".to_string()]);
222        assert_eq!(c.loopback_url.as_deref(), Some("http://127.0.0.1:3000"));
223        assert_eq!(c.base_url, None, "empty baseUrl is unset");
224        assert_eq!(
225            c.public_origin(Some("https://h.example/")),
226            "https://h.example"
227        );
228        let c2 = ForgeConfig::from_json(&json!({"baseUrl": "https://pub.example/"}));
229        assert_eq!(
230            c2.public_origin(Some("https://h.example")),
231            "https://pub.example"
232        );
233        assert_eq!(ForgeConfig::default().public_origin(None), "");
234    }
235}