use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("configuration error: {0}")]
Config(String),
}
pub type Result<T> = std::result::Result<T, Error>;
macro_rules! validated_id {
($(#[$doc:meta])* $name:ident) => {
$(#[$doc])*
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct $name(String);
impl $name {
fn validate(raw: &str) -> std::result::Result<(), String> {
if raw.is_empty() {
return Err(concat!(stringify!($name), " cannot be empty").to_owned());
}
if raw.chars().any(char::is_whitespace) {
return Err(concat!(stringify!($name), " cannot contain whitespace").to_owned());
}
Ok(())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for $name {
type Error = String;
fn try_from(raw: String) -> std::result::Result<Self, Self::Error> {
Self::validate(&raw)?;
Ok(Self(raw))
}
}
impl TryFrom<&str> for $name {
type Error = String;
fn try_from(raw: &str) -> std::result::Result<Self, Self::Error> {
Self::try_from(raw.to_owned())
}
}
impl From<$name> for String {
fn from(id: $name) -> Self {
id.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
};
}
validated_id! {
DocumentId
}
validated_id! {
UserId
}
validated_id! {
SessionId
}
#[cfg(test)]
mod id_tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
#[test]
fn ids_accept_plain_names() {
assert!(DocumentId::try_from("contract-2026").is_ok());
assert!(UserId::try_from("alice").is_ok());
}
#[test]
fn ids_reject_empty_and_whitespace() {
assert!(DocumentId::try_from("").is_err());
assert!(UserId::try_from("alice smith").is_err());
assert!(SessionId::try_from("\t").is_err());
}
#[test]
fn ids_round_trip_through_serde() {
let doc: DocumentId = serde_json::from_str("\"contract-2026\"").expect("valid json");
assert_eq!(doc.as_str(), "contract-2026");
assert!(serde_json::from_str::<DocumentId>("\"\"").is_err());
}
#[test]
fn display_matches_inner() {
let user = UserId::try_from("bob").expect("valid");
assert_eq!(user.to_string(), "bob");
}
}
#[derive(Debug, Deserialize)]
pub struct ServiceSettings {
#[serde(default = "default_bind")]
pub bind: String,
pub database_url: config_kit::Sensitive<String>,
pub asset_store: String,
}
fn default_bind() -> String {
"127.0.0.1:8080".to_owned()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DesktopSettings {
#[serde(default)]
pub team_library: Option<String>,
#[serde(default)]
pub telemetry_endpoint: Option<String>,
}
impl ServiceSettings {
pub fn load(file: &std::path::Path) -> Result<Self> {
config_kit::ConfigBuilder::new()
.layer(config_kit::ConfigLayer::file(file))
.layer(config_kit::ConfigLayer::env_prefix("CHISEL"))
.load_strict()
.map_err(|e| Error::Config(e.to_string()))
}
}
impl DesktopSettings {
pub fn load(file: Option<&std::path::Path>) -> Result<Self> {
match file {
Some(f) => config_kit::ConfigBuilder::new()
.layer(config_kit::ConfigLayer::file(f))
.load()
.map_err(|e| Error::Config(e.to_string())),
None => Ok(Self::default()),
}
}
}
#[cfg(test)]
mod settings_tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
use std::io::Write as _;
fn tmp_file(name: &str, contents: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("{name}-{}", std::process::id()));
let mut f = std::fs::File::create(&p).expect("create temp file");
f.write_all(contents.as_bytes()).expect("write temp file");
p
}
#[test]
fn service_settings_load_strictly() {
let p = tmp_file(
"svc",
r#"bind = "127.0.0.1:9000"
database_url = "postgres://localhost/chisel"
asset_store = "/var/lib/chisel"
"#,
);
let s = ServiceSettings::load(&p).expect("loads");
assert_eq!(s.bind, "127.0.0.1:9000");
assert_eq!(s.database_url.expose(), "postgres://localhost/chisel");
let dbg = format!("{:?}", s.database_url);
assert!(!dbg.contains("postgres"), "secrets must be redacted: {dbg}");
std::fs::remove_file(p).ok();
}
#[test]
fn service_settings_reject_unknown_keys() {
let p = tmp_file(
"svc-bad",
r#"bind = "127.0.0.1:9000"
database_url = "postgres://localhost/chisel"
asset_store = "/var/lib/chisel"
mystery = true
"#,
);
assert!(
ServiceSettings::load(&p).is_err(),
"unknown keys must fail strict load"
);
std::fs::remove_file(p).ok();
}
#[test]
fn desktop_settings_default_when_no_file() {
let s = DesktopSettings::load(None).expect("defaults");
assert!(s.team_library.is_none());
assert!(s.telemetry_endpoint.is_none());
}
#[test]
fn desktop_settings_tolerate_unknown_keys() {
let p = tmp_file(
"desk",
r#"team_library = "https://team.example"
leftover_from_old_version = 7
"#,
);
let s = DesktopSettings::load(Some(&p)).expect("lenient load");
assert_eq!(s.team_library.as_deref(), Some("https://team.example"));
std::fs::remove_file(p).ok();
}
}