//! Layered configuration: defaults, the user file, the repository file. use super::*; /// Effective settings: defaults, then the user file (`~/.config/oxy/config.toml`, /// spec §19), then the repository file (`.oxy/config`). Both files are /// `key = "value"` lines. `save` writes only the repository layer. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Config { /// Keys set in the repository file (or via `set`), written by `save`. repo_layer: BTreeMap, pub author_name: String, pub author_email: String, /// The KeyId of the author's identity, once `create_identity` has run. pub author_key: Option, /// `core.sign`: sign every change at commit (`true`) or leave signing to /// push-time seals (`false`, the spec default). pub sign_changes: bool, pub bookmark: String, /// `remote..url`. pub remotes: BTreeMap, /// `remote..token`: bearer token for HTTP pushes. pub remote_tokens: BTreeMap, /// `remote..ca`: PEM file of extra roots to trust for that remote. pub remote_cas: BTreeMap, /// `core.op-retention`: days for which operations keep their states reachable (spec §12.1, §21). pub op_retention_days: u64, /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21). pub gc_grace_days: u64, /// `core.checkpoint-interval`: changes between automatic checkpoints (spec §11). pub checkpoint_interval: u64, /// `filter.max-blob`: blobs larger than this many bytes are fetched lazily (spec §17). pub max_blob: Option, /// `filter.paths`: only these subtrees are materialized (spec §17); empty means everything. pub sparse_paths: Vec, /// `hooks.semantic-deps`: run the semantic-deps hook at commit (spec §20). pub semantic_deps: bool, } impl Config { pub(super) fn default_for_user() -> Config { let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into()); Config { repo_layer: BTreeMap::new(), author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()), author_email: std::env::var("OXY_AUTHOR_EMAIL") .unwrap_or_else(|_| format!("{user}@localhost")), author_key: None, sign_changes: false, bookmark: "main".into(), remotes: BTreeMap::new(), remote_tokens: BTreeMap::new(), remote_cas: BTreeMap::new(), op_retention_days: 90, gc_grace_days: 14, checkpoint_interval: 256, max_blob: None, sparse_paths: Vec::new(), semantic_deps: false, } } /// Set a key in the repository layer, as `oxy config` does. pub fn set(&mut self, key: &str, value: &str) -> Result<()> { self.apply(key, value)?; if value.is_empty() { self.repo_layer.remove(key); } else { self.repo_layer.insert(key.to_string(), value.to_string()); } Ok(()) } /// Set a key in the user file without touching any repository. pub fn set_user(user_path: &Path, key: &str, value: &str) -> Result<()> { Config::default_for_user().apply(key, value)?; let mut entries = read_config_file(user_path)?; if value.is_empty() { entries.remove(key); } else { entries.insert(key.to_string(), value.to_string()); } if let Some(parent) = user_path.parent() { fs::create_dir_all(parent)?; } write_config_file(user_path, &entries) } /// Apply a key to the effective values. pub(super) fn apply(&mut self, key: &str, value: &str) -> Result<()> { match key { "author.name" => self.author_name = value.into(), "author.email" => self.author_email = value.into(), "author.key" => { self.author_key = if value.is_empty() { None } else { Some(KeyId::from_hex(value).ok_or_else(|| { RepoError::Msg("author.key must be a 64-character hex KeyId".into()) })?) } } "core.sign" => { self.sign_changes = match value { "change" => true, "seal" => false, _ => return fail("core.sign must be \"change\" or \"seal\""), } } "workspace.bookmark" => self.bookmark = value.into(), "core.op-retention" => self.op_retention_days = parse_days(value)?, "core.gc-grace" => self.gc_grace_days = parse_days(value)?, "filter.max-blob" => { self.max_blob = if value.is_empty() { None } else { Some(parse_size(value)?) } } "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"), "filter.paths" => { self.sparse_paths = value .split(',') .map(|p| { p.trim() .trim_start_matches("./") .trim_matches('/') .to_string() }) .filter(|p| !p.is_empty()) .collect(); } "core.checkpoint-interval" => { self.checkpoint_interval = value .parse() .map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?; } k if k.starts_with("remote.") => { let rest = &k["remote.".len()..]; let Some((name, field)) = rest.rsplit_once('.') else { return fail(format!("unknown configuration key {key:?}")); }; if name.is_empty() || name.contains('/') { return fail(format!("invalid remote name in {key:?}")); } let map = match field { "url" => &mut self.remotes, "token" => &mut self.remote_tokens, "ca" => &mut self.remote_cas, _ => return fail(format!("unknown configuration key {key:?}")), }; if value.is_empty() { map.remove(name); if field == "url" { self.remote_tokens.remove(name); self.remote_cas.remove(name); } } else { map.insert(name.to_string(), value.into()); } } _ => return fail(format!("unknown configuration key {key:?}")), } Ok(()) } /// Defaults, then the user file if it exists, then the repository file. pub(super) fn load(user_path: Option<&Path>, repo_path: &Path) -> Result { let mut c = Config::default_for_user(); if let Some(u) = user_path { for (k, v) in read_config_file(u)? { let _ = c.apply(&k, &v); } } for (k, v) in read_config_file(repo_path)? { if c.apply(&k, &v).is_ok() { c.repo_layer.insert(k, v); } } Ok(c) } pub(super) fn save(&self, path: &Path) -> Result<()> { write_config_file(path, &self.repo_layer) } /// Keys explicitly set for this repository. pub fn repo_layer(&self) -> &BTreeMap { &self.repo_layer } } /// A byte count with an optional `K`, `M`, or `G` suffix (binary units). pub fn parse_size(v: &str) -> Result { let v = v.trim(); let (num, mult) = match v.chars().last() { Some('K' | 'k') => (&v[..v.len() - 1], 1u64 << 10), Some('M' | 'm') => (&v[..v.len() - 1], 1u64 << 20), Some('G' | 'g') => (&v[..v.len() - 1], 1u64 << 30), _ => (v, 1), }; let n: u64 = num .trim() .parse() .map_err(|_| RepoError::Msg(format!("expected a size like 1M, got {v:?}")))?; Ok(n * mult) } /// A count of days, optionally suffixed `d`. fn parse_days(v: &str) -> Result { v.trim_end_matches('d') .parse() .map_err(|_| RepoError::Msg(format!("expected a number of days, got {v:?}"))) } /// Read `key = "value"` lines; a missing file is empty. pub fn read_config_file(path: &Path) -> Result> { let text = match fs::read_to_string(path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), Err(e) => return Err(e.into()), }; let mut out = BTreeMap::new(); for line in text.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let Some((k, v)) = line.split_once('=') else { continue; }; out.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string()); } Ok(out) } fn write_config_file(path: &Path, entries: &BTreeMap) -> Result<()> { let text: String = entries .iter() .map(|(k, v)| format!("{k} = \"{v}\"\n")) .collect(); write_atomic(path, text.as_bytes()) }