Skip to main content

chisel_kernel/
lib.rs

1//! Chisel platform kernel: typed configuration and validated identifiers.
2//!
3//! Two jobs, both boring on purpose:
4//!
5//! 1. **Typed, layered settings** for the desktop app and the service, driven
6//!    by the estate's [`config_kit`] (file → env → overrides, strict keys,
7//!    redacted secrets).
8//! 2. **Validated identifiers** — [`DocumentId`], [`UserId`], [`SessionId`] —
9//!    so the rest of the platform cannot pass a filename where a document id
10//!    belongs.
11//!
12//! Deliberately absent: anything PDF-shaped. The editor owns that; the kernel
13//! only carries what every platform crate needs.
14
15use std::fmt;
16
17use serde::{Deserialize, Serialize};
18
19/// Kernel failures.
20#[derive(Debug, thiserror::Error)]
21pub enum Error {
22    /// Settings failed to load or validate.
23    #[error("configuration error: {0}")]
24    Config(String),
25}
26
27/// Convenient alias for kernel operations.
28pub type Result<T> = std::result::Result<T, Error>;
29
30/// Generates a newtype over `String` that validates on construction and
31/// refuses mutation, so a `UserId` can never hold an empty or whitespace name.
32macro_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            /// The identifier as borrowed text.
51            #[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    /// A document identity inside the platform (not the file name; the stable
90    /// id the assets store indexes by).
91    DocumentId
92}
93validated_id! {
94    /// A human or machine actor on the platform.
95    UserId
96}
97validated_id! {
98    /// An authenticated session.
99    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/// Service settings: everything the platform side needs before a single
135/// document moves. Secrets live in [`Sensitive`](config_kit::Sensitive) so a
136/// stray `{:?}` cannot leak them. Deliberately not `Clone` and **not
137/// `Serialize`**: `Sensitive`'s serializer is opt-in for a reason — a settings
138/// struct that can be serialized is one debug endpoint away from leaking its
139/// DSN.
140#[derive(Debug, Deserialize)]
141pub struct ServiceSettings {
142    /// Bind address for the HTTP service.
143    #[serde(default = "default_bind")]
144    pub bind: String,
145    /// Postgres DSN for the outbox and trail continuation.
146    pub database_url: config_kit::Sensitive<String>,
147    /// Blob store root (S3 URL or a filesystem path).
148    pub asset_store: String,
149}
150
151fn default_bind() -> String {
152    "127.0.0.1:8080".to_owned()
153}
154
155/// Desktop settings: what the editor itself reads at startup. Everything
156/// optional-with-default, because a fresh install must open with zero config.
157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
158pub struct DesktopSettings {
159    /// Where team assets are fetched from; absent means local-only mode.
160    #[serde(default)]
161    pub team_library: Option<String>,
162    /// Opt-in telemetry endpoint; absent means fully off.
163    #[serde(default)]
164    pub telemetry_endpoint: Option<String>,
165}
166
167impl ServiceSettings {
168    /// Loads service settings from `file`, then `CHISEL_*` env — the
169    /// documented precedence (later layers win), strict about unknown keys.
170    ///
171    /// # Errors
172    ///
173    /// Any layer failing to parse, or strict unknown keys.
174    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    /// Loads desktop settings leniently (unknown keys ignored: an old config
185    /// file must not strand a fresh install) with no file when absent.
186    ///
187    /// # Errors
188    ///
189    /// Only when a file exists and fails to parse.
190    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        // redaction: Debug must not leak the DSN
228        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}