//! 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, /// `author.signer` (or `$OXY_SIGNER`): the key that signs and seals in /// place of `author.key`, whose identity lists it as a delegate. An /// agent sets it to its own key, so its changes carry the human as /// author and the agent's signature (spec §14.2, agent workflows §2). pub signer_key: Option, /// `author.signer = "keyless"`: sign with fresh keys the hub certifies /// under the agent's grant, keeping no key on disk (spec §14.7, /// oxygenhub#241). pub signer_keyless: bool, /// `keyless.hub`: the hub that certifies them (default: the default /// remote's hub). pub keyless_hub: 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.remote`: the remote `pull`, `fetch` and `push` use when none is /// named, for a repository whose remotes include no `origin`. pub default_remote: Option, /// `git.claim`: a Git import authors the commits whose Git author email /// is this repository's identity's with its key (oxygen#97). pub git_claim: bool, /// `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, /// `push.batch-bytes`: about how many bytes of changes and their texts /// one request of a long push carries (spec §16.3). pub push_batch_bytes: 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, /// `revset.`: a saved revset, used as `name()`. pub revsets: BTreeMap, /// `storage.url`: the store the repository's objects and operation /// head are kept on (`dir://`, `s3://`, …) instead of `.oxy/objects` /// (oxygen#68). pub storage_url: Option, /// `storage.cache`: the most the local cache in front of that store /// holds, in bytes. pub storage_cache: u64, } impl Config { /// Whether the repository's own file sets `key` (as against the user /// config or a default). pub fn is_repo_set(&self, key: &str) -> bool { self.repo_layer.contains_key(key) } 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, signer_key: std::env::var("OXY_SIGNER") .ok() .and_then(|v| KeyId::from_hex(&v)), signer_keyless: std::env::var("OXY_SIGNER").is_ok_and(|v| v == "keyless"), keyless_hub: None, sign_changes: false, bookmark: "main".into(), remotes: BTreeMap::new(), remote_tokens: BTreeMap::new(), remote_cas: BTreeMap::new(), default_remote: None, git_claim: false, op_retention_days: 90, gc_grace_days: 14, checkpoint_interval: 256, push_batch_bytes: 256 << 20, max_blob: None, sparse_paths: Vec::new(), semantic_deps: false, revsets: BTreeMap::new(), storage_url: None, storage_cache: DEFAULT_STORAGE_CACHE, } } /// 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()) })?) } } "author.signer" => { self.signer_keyless = value == "keyless"; self.signer_key = if value.is_empty() || self.signer_keyless { None } else { Some(KeyId::from_hex(value).ok_or_else(|| { RepoError::Msg( "author.signer must be a 64-character hex KeyId, or \"keyless\"".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(), "keyless.hub" => self.keyless_hub = (!value.is_empty()).then(|| value.to_string()), // Read by the credential store from the user file. "auth.helper" => {} "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)?) } } "storage.url" => { self.storage_url = (!value.is_empty()).then(|| value.to_string()); } "storage.cache" => { self.storage_cache = if value.is_empty() { DEFAULT_STORAGE_CACHE } else { parse_size(value)? } } "core.remote" => { self.default_remote = (!value.is_empty()).then(|| value.to_string()); } "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"), "git.claim" => self.git_claim = 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:?}")))?; } "push.batch-bytes" => { self.push_batch_bytes = value.parse().ok().filter(|n| *n > 0).ok_or_else(|| { RepoError::Msg(format!("expected a number of bytes, 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()); } } k if k.starts_with("revset.") => { let name = &k["revset.".len()..]; if name.is_empty() || !name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { return fail(format!( "a revset's name is letters, digits, _ and -: {key:?}" )); } if value.is_empty() { self.revsets.remove(name); } else { crate::revset::parse(value) .map_err(|e| RepoError::Msg(format!("{key} = {value:?}: {e}")))?; self.revsets.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). /// `storage.cache` when unset: 2 GiB. pub const DEFAULT_STORAGE_CACHE: u64 = 2 << 30; 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()) }