OXYGENHUB

Coverage / crates/oxy-core/src/repo/config.rs 85.0%

182 of 214 lines run, 36 of 56 functions · run #259 on main · run missed

1
//! Layered configuration: defaults, the user file, the repository file.
2
3
use super::*;
4
5
/// Effective settings: defaults, then the user file (`~/.config/oxy/config.toml`,
6
/// spec §19), then the repository file (`.oxy/config`). Both files are
7
/// `key = "value"` lines. `save` writes only the repository layer.
8
#[derive(Clone, Debug, PartialEq, Eq)]
9
pub struct Config {
10
    /// Keys set in the repository file (or via `set`), written by `save`.
11
    repo_layer: BTreeMap<String, String>,
12
    pub author_name: String,
13
    pub author_email: String,
14
    /// The KeyId of the author's identity, once `create_identity` has run.
15
    pub author_key: Option<KeyId>,
16
    /// `author.signer` (or `$OXY_SIGNER`): the key that signs and seals in
17
    /// place of `author.key`, whose identity lists it as a delegate. An
18
    /// agent sets it to its own key, so its changes carry the human as
19
    /// author and the agent's signature (spec §14.2, agent workflows §2).
20
    pub signer_key: Option<KeyId>,
21
    /// `core.sign`: sign every change at commit (`true`) or leave signing to
22
    /// push-time seals (`false`, the spec default).
23
    pub sign_changes: bool,
24
    pub bookmark: String,
25
    /// `remote.<name>.url`.
26
    pub remotes: BTreeMap<String, String>,
27
    /// `remote.<name>.token`: bearer token for HTTP pushes.
28
    pub remote_tokens: BTreeMap<String, String>,
29
    /// `remote.<name>.ca`: PEM file of extra roots to trust for that remote.
30
    pub remote_cas: BTreeMap<String, String>,
31
    /// `core.remote`: the remote `pull`, `fetch` and `push` use when none is
32
    /// named, for a repository whose remotes include no `origin`.
33
    pub default_remote: Option<String>,
34
    /// `core.op-retention`: days for which operations keep their states reachable (spec §12.1, §21).
35
    pub op_retention_days: u64,
36
    /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21).
37
    pub gc_grace_days: u64,
38
    /// `core.checkpoint-interval`: changes between automatic checkpoints (spec §11).
39
    pub checkpoint_interval: u64,
40
    /// `filter.max-blob`: blobs larger than this many bytes are fetched lazily (spec §17).
41
    pub max_blob: Option<u64>,
42
    /// `filter.paths`: only these subtrees are materialized (spec §17); empty means everything.
43
    pub sparse_paths: Vec<String>,
44
    /// `hooks.semantic-deps`: run the semantic-deps hook at commit (spec §20).
45
    pub semantic_deps: bool,
46
    /// `revset.<name>`: a saved revset, used as `name()`.
47
    pub revsets: BTreeMap<String, String>,
48
    /// `storage.url`: the store the repository's objects and operation
49
    /// head are kept on (`dir://`, `s3://`, …) instead of `.oxy/objects`
50
    /// (oxygen#68).
51
    pub storage_url: Option<String>,
52
    /// `storage.cache`: the most the local cache in front of that store
53
    /// holds, in bytes.
54
    pub storage_cache: u64,
55
}
56
57
impl Config {
58
    /// Whether the repository's own file sets `key` (as against the user
59
    /// config or a default).
602×
    pub fn is_repo_set(&self, key: &str) -> bool {
612×
        self.repo_layer.contains_key(key)
622×
    }
63
642615×
    pub(super) fn default_for_user() -> Config {
652615×
        let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
66
        Config {
672615×
            repo_layer: BTreeMap::new(),
682615×
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
692615×
            author_email: std::env::var("OXY_AUTHOR_EMAIL")
702615×
                .unwrap_or_else(|_| format!("{user}@localhost")),
712615×
            author_key: None,
722615×
            signer_key: std::env::var("OXY_SIGNER")
732615×
                .ok()
742615×
                .and_then(|v| KeyId::from_hex(&v)),
75
            sign_changes: false,
762615×
            bookmark: "main".into(),
772615×
            remotes: BTreeMap::new(),
782615×
            remote_tokens: BTreeMap::new(),
792615×
            remote_cas: BTreeMap::new(),
802615×
            default_remote: None,
81
            op_retention_days: 90,
82
            gc_grace_days: 14,
83
            checkpoint_interval: 256,
842615×
            max_blob: None,
852615×
            sparse_paths: Vec::new(),
86
            semantic_deps: false,
872615×
            revsets: BTreeMap::new(),
882615×
            storage_url: None,
89
            storage_cache: DEFAULT_STORAGE_CACHE,
90
        }
912615×
    }
92
93
    /// Set a key in the repository layer, as `oxy config` does.
94443×
    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
95443×
        self.apply(key, value)?;
96443×
        if value.is_empty() {
972×
            self.repo_layer.remove(key);
98441×
        } else {
99441×
            self.repo_layer.insert(key.to_string(), value.to_string());
100441×
        }
101443×
        Ok(())
102443×
    }
103
104
    /// Set a key in the user file without touching any repository.
10591×
    pub fn set_user(user_path: &Path, key: &str, value: &str) -> Result<()> {
10691×
        Config::default_for_user().apply(key, value)?;
10790×
        let mut entries = read_config_file(user_path)?;
10890×
        if value.is_empty() {
1090×
            entries.remove(key);
11090×
        } else {
11190×
            entries.insert(key.to_string(), value.to_string());
11290×
        }
11390×
        if let Some(parent) = user_path.parent() {
11490×
            fs::create_dir_all(parent)?;
1150×
        }
11690×
        write_config_file(user_path, &entries)
11791×
    }
118
119
    /// Apply a key to the effective values.
1201476×
    pub(super) fn apply(&mut self, key: &str, value: &str) -> Result<()> {
1213×
        match key {
1221476×
            "author.name" => self.author_name = value.into(),
1231232×
            "author.email" => self.author_email = value.into(),
124988×
            "author.key" => {
125246×
                self.author_key = if value.is_empty() {
1260×
                    None
127
                } else {
128246×
                    Some(KeyId::from_hex(value).ok_or_else(|| {
1290×
                        RepoError::Msg("author.key must be a 64-character hex KeyId".into())
1300×
                    })?)
131
                }
132
            }
133742×
            "author.signer" => {
1343×
                self.signer_key = if value.is_empty() {
1350×
                    None
136
                } else {
1373×
                    Some(KeyId::from_hex(value).ok_or_else(|| {
1380×
                        RepoError::Msg("author.signer must be a 64-character hex KeyId".into())
1390×
                    })?)
140
                }
141
            }
142739×
            "core.sign" => {
14383×
                self.sign_changes = match value {
14483×
                    "change" => true,
1457×
                    "seal" => false,
1460×
                    _ => return fail("core.sign must be \"change\" or \"seal\""),
147
                }
148
            }
149656×
            "workspace.bookmark" => self.bookmark = value.into(),
150
            // Read by the credential store from the user file.
151652×
            "auth.helper" => {}
152652×
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
153640×
            "core.gc-grace" => self.gc_grace_days = parse_days(value)?,
154640×
            "filter.max-blob" => {
1555×
                self.max_blob = if value.is_empty() {
1560×
                    None
157
                } else {
1585×
                    Some(parse_size(value)?)
159
                }
160
            }
161635×
            "storage.url" => {
1625×
                self.storage_url = (!value.is_empty()).then(|| value.to_string());
163
            }
164630×
            "storage.cache" => {
1650×
                self.storage_cache = if value.is_empty() {
1660×
                    DEFAULT_STORAGE_CACHE
167
                } else {
1680×
                    parse_size(value)?
169
                }
170
            }
171630×
            "core.remote" => {
1721×
                self.default_remote = (!value.is_empty()).then(|| value.to_string());
173
            }
174629×
            "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"),
175628×
            "filter.paths" => {
1763×
                self.sparse_paths = value
1773×
                    .split(',')
1784×
                    .map(|p| {
1794×
                        p.trim()
1804×
                            .trim_start_matches("./")
1814×
                            .trim_matches('/')
1824×
                            .to_string()
1834×
                    })
1844×
                    .filter(|p| !p.is_empty())
1853×
                    .collect();
186
            }
187625×
            "core.checkpoint-interval" => {
1883×
                self.checkpoint_interval = value
1893×
                    .parse()
1903×
                    .map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?;
191
            }
192622×
            k if k.starts_with("remote.") => {
193619×
                let rest = &k["remote.".len()..];
194619×
                let Some((name, field)) = rest.rsplit_once('.') else {
1950×
                    return fail(format!("unknown configuration key {key:?}"));
196
                };
197619×
                if name.is_empty() || name.contains('/') {
1980×
                    return fail(format!("invalid remote name in {key:?}"));
199619×
                }
200619×
                let map = match field {
201619×
                    "url" => &mut self.remotes,
20210×
                    "token" => &mut self.remote_tokens,
2034×
                    "ca" => &mut self.remote_cas,
2040×
                    _ => return fail(format!("unknown configuration key {key:?}")),
205
                };
206619×
                if value.is_empty() {
2072×
                    map.remove(name);
2082×
                    if field == "url" {
2092×
                        self.remote_tokens.remove(name);
2102×
                        self.remote_cas.remove(name);
2112×
                    }
212617×
                } else {
213617×
                    map.insert(name.to_string(), value.into());
214617×
                }
215
            }
2163×
            k if k.starts_with("revset.") => {
2173×
                let name = &k["revset.".len()..];
2183×
                if name.is_empty()
2193×
                    || !name
2203×
                        .chars()
22111×
                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
222
                {
2230×
                    return fail(format!(
224
                        "a revset's name is letters, digits, _ and -: {key:?}"
225
                    ));
2263×
                }
2273×
                if value.is_empty() {
2280×
                    self.revsets.remove(name);
2290×
                } else {
2303×
                    crate::revset::parse(value)
2313×
                        .map_err(|e| RepoError::Msg(format!("{key} = {value:?}: {e}")))?;
2322×
                    self.revsets.insert(name.to_string(), value.into());
233
                }
234
            }
2350×
            _ => return fail(format!("unknown configuration key {key:?}")),
236
        }
2371475×
        Ok(())
2381476×
    }
239
240
    /// Defaults, then the user file if it exists, then the repository file.
2412524×
    pub(super) fn load(user_path: Option<&Path>, repo_path: &Path) -> Result<Config> {
2422524×
        let mut c = Config::default_for_user();
2432524×
        if let Some(u) = user_path {
2442524×
            for (k, v) in read_config_file(u)? {
245294×
                let _ = c.apply(&k, &v);
246294×
            }
2470×
        }
2482524×
        for (k, v) in read_config_file(repo_path)? {
249648×
            if c.apply(&k, &v).is_ok() {
250648×
                c.repo_layer.insert(k, v);
251648×
            }
252
        }
2532524×
        Ok(c)
2542524×
    }
255
256840×
    pub(super) fn save(&self, path: &Path) -> Result<()> {
257840×
        write_config_file(path, &self.repo_layer)
258840×
    }
259
260
    /// Keys explicitly set for this repository.
26115×
    pub fn repo_layer(&self) -> &BTreeMap<String, String> {
26215×
        &self.repo_layer
26315×
    }
264
}
265
266
/// A byte count with an optional `K`, `M`, or `G` suffix (binary units).
267
/// `storage.cache` when unset: 2 GiB.
268
pub const DEFAULT_STORAGE_CACHE: u64 = 2 << 30;
269
2707×
pub fn parse_size(v: &str) -> Result<u64> {
2717×
    let v = v.trim();
2727×
    let (num, mult) = match v.chars().last() {
2731×
        Some('K' | 'k') => (&v[..v.len() - 1], 1u64 << 10),
2741×
        Some('M' | 'm') => (&v[..v.len() - 1], 1u64 << 20),
2750×
        Some('G' | 'g') => (&v[..v.len() - 1], 1u64 << 30),
2765×
        _ => (v, 1),
277
    };
2787×
    let n: u64 = num
2797×
        .trim()
2807×
        .parse()
2817×
        .map_err(|_| RepoError::Msg(format!("expected a size like 1M, got {v:?}")))?;
2826×
    Ok(n * mult)
2837×
}
284
285
/// A count of days, optionally suffixed `d`.
28612×
fn parse_days(v: &str) -> Result<u64> {
28712×
    v.trim_end_matches('d')
28812×
        .parse()
28912×
        .map_err(|_| RepoError::Msg(format!("expected a number of days, got {v:?}")))
29012×
}
291
292
/// Read `key = "value"` lines; a missing file is empty.
2935587×
pub fn read_config_file(path: &Path) -> Result<BTreeMap<String, String>> {
2945587×
    let text = match fs::read_to_string(path) {
2952200×
        Ok(t) => t,
2963387×
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
2970×
        Err(e) => return Err(e.into()),
298
    };
2992200×
    let mut out = BTreeMap::new();
3002200×
    for line in text.lines() {
3011146×
        let line = line.trim();
3021146×
        if line.is_empty() || line.starts_with('#') {
3030×
            continue;
3041146×
        }
3051146×
        let Some((k, v)) = line.split_once('=') else {
3060×
            continue;
307
        };
3081146×
        out.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string());
309
    }
3102200×
    Ok(out)
3115587×
}
312
313930×
fn write_config_file(path: &Path, entries: &BTreeMap<String, String>) -> Result<()> {
314930×
    let text: String = entries
315930×
        .iter()
316930×
        .map(|(k, v)| format!("{k} = \"{v}\"\n"))
317930×
        .collect();
318930×
    write_atomic(path, text.as_bytes())
319930×
}