solid_pod_rs_forge/
config_file.rs1use std::path::Path;
14
15use serde_json::Value;
16
17use crate::config::ForgeConfig;
18use crate::error::ForgeError;
19
20pub const FORGE_CONFIG_ENV: &str = "FORGE_CONFIG";
22
23pub 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#[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 #[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 #[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 #[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 #[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}