1use std::fmt;
16
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, thiserror::Error)]
21pub enum Error {
22 #[error("configuration error: {0}")]
24 Config(String),
25}
26
27pub type Result<T> = std::result::Result<T, Error>;
29
30macro_rules! validated_id {
33 ($(#[$doc:meta])* $name:ident) => {
34 $(#[$doc])*
35 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
36 #[serde(try_from = "String", into = "String")]
37 pub struct $name(String);
38
39 impl $name {
40 fn validate(raw: &str) -> std::result::Result<(), String> {
41 if raw.is_empty() {
42 return Err(concat!(stringify!($name), " cannot be empty").to_owned());
43 }
44 if raw.chars().any(char::is_whitespace) {
45 return Err(concat!(stringify!($name), " cannot contain whitespace").to_owned());
46 }
47 Ok(())
48 }
49
50 #[must_use]
52 pub fn as_str(&self) -> &str {
53 &self.0
54 }
55 }
56
57 impl TryFrom<String> for $name {
58 type Error = String;
59
60 fn try_from(raw: String) -> std::result::Result<Self, Self::Error> {
61 Self::validate(&raw)?;
62 Ok(Self(raw))
63 }
64 }
65
66 impl TryFrom<&str> for $name {
67 type Error = String;
68
69 fn try_from(raw: &str) -> std::result::Result<Self, Self::Error> {
70 Self::try_from(raw.to_owned())
71 }
72 }
73
74 impl From<$name> for String {
75 fn from(id: $name) -> Self {
76 id.0
77 }
78 }
79
80 impl fmt::Display for $name {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 f.write_str(&self.0)
83 }
84 }
85 };
86}
87
88validated_id! {
89 DocumentId
92}
93validated_id! {
94 UserId
96}
97validated_id! {
98 SessionId
100}
101
102#[cfg(test)]
103mod id_tests {
104 #![allow(clippy::expect_used, clippy::unwrap_used)]
105 use super::*;
106
107 #[test]
108 fn ids_accept_plain_names() {
109 assert!(DocumentId::try_from("contract-2026").is_ok());
110 assert!(UserId::try_from("alice").is_ok());
111 }
112
113 #[test]
114 fn ids_reject_empty_and_whitespace() {
115 assert!(DocumentId::try_from("").is_err());
116 assert!(UserId::try_from("alice smith").is_err());
117 assert!(SessionId::try_from("\t").is_err());
118 }
119
120 #[test]
121 fn ids_round_trip_through_serde() {
122 let doc: DocumentId = serde_json::from_str("\"contract-2026\"").expect("valid json");
123 assert_eq!(doc.as_str(), "contract-2026");
124 assert!(serde_json::from_str::<DocumentId>("\"\"").is_err());
125 }
126
127 #[test]
128 fn display_matches_inner() {
129 let user = UserId::try_from("bob").expect("valid");
130 assert_eq!(user.to_string(), "bob");
131 }
132}
133
134#[derive(Debug, Deserialize)]
141pub struct ServiceSettings {
142 #[serde(default = "default_bind")]
144 pub bind: String,
145 pub database_url: config_kit::Sensitive<String>,
147 pub asset_store: String,
149}
150
151fn default_bind() -> String {
152 "127.0.0.1:8080".to_owned()
153}
154
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
158pub struct DesktopSettings {
159 #[serde(default)]
161 pub team_library: Option<String>,
162 #[serde(default)]
164 pub telemetry_endpoint: Option<String>,
165}
166
167impl ServiceSettings {
168 pub fn load(file: &std::path::Path) -> Result<Self> {
175 config_kit::ConfigBuilder::new()
176 .layer(config_kit::ConfigLayer::file(file))
177 .layer(config_kit::ConfigLayer::env_prefix("CHISEL"))
178 .load_strict()
179 .map_err(|e| Error::Config(e.to_string()))
180 }
181}
182
183impl DesktopSettings {
184 pub fn load(file: Option<&std::path::Path>) -> Result<Self> {
191 match file {
192 Some(f) => config_kit::ConfigBuilder::new()
193 .layer(config_kit::ConfigLayer::file(f))
194 .load()
195 .map_err(|e| Error::Config(e.to_string())),
196 None => Ok(Self::default()),
197 }
198 }
199}
200
201#[cfg(test)]
202mod settings_tests {
203 #![allow(clippy::expect_used, clippy::unwrap_used)]
204 use super::*;
205 use std::io::Write as _;
206
207 fn tmp_file(name: &str, contents: &str) -> std::path::PathBuf {
208 let mut p = std::env::temp_dir();
209 p.push(format!("{name}-{}", std::process::id()));
210 let mut f = std::fs::File::create(&p).expect("create temp file");
211 f.write_all(contents.as_bytes()).expect("write temp file");
212 p
213 }
214
215 #[test]
216 fn service_settings_load_strictly() {
217 let p = tmp_file(
218 "svc",
219 r#"bind = "127.0.0.1:9000"
220database_url = "postgres://localhost/chisel"
221asset_store = "/var/lib/chisel"
222"#,
223 );
224 let s = ServiceSettings::load(&p).expect("loads");
225 assert_eq!(s.bind, "127.0.0.1:9000");
226 assert_eq!(s.database_url.expose(), "postgres://localhost/chisel");
227 let dbg = format!("{:?}", s.database_url);
229 assert!(!dbg.contains("postgres"), "secrets must be redacted: {dbg}");
230 std::fs::remove_file(p).ok();
231 }
232
233 #[test]
234 fn service_settings_reject_unknown_keys() {
235 let p = tmp_file(
236 "svc-bad",
237 r#"bind = "127.0.0.1:9000"
238database_url = "postgres://localhost/chisel"
239asset_store = "/var/lib/chisel"
240mystery = true
241"#,
242 );
243 assert!(
244 ServiceSettings::load(&p).is_err(),
245 "unknown keys must fail strict load"
246 );
247 std::fs::remove_file(p).ok();
248 }
249
250 #[test]
251 fn desktop_settings_default_when_no_file() {
252 let s = DesktopSettings::load(None).expect("defaults");
253 assert!(s.team_library.is_none());
254 assert!(s.telemetry_endpoint.is_none());
255 }
256
257 #[test]
258 fn desktop_settings_tolerate_unknown_keys() {
259 let p = tmp_file(
260 "desk",
261 r#"team_library = "https://team.example"
262leftover_from_old_version = 7
263"#,
264 );
265 let s = DesktopSettings::load(Some(&p)).expect("lenient load");
266 assert_eq!(s.team_library.as_deref(), Some("https://team.example"));
267 std::fs::remove_file(p).ok();
268 }
269}