OXYGENHUB
# 47ce8b8e2d4b oxygen / crates / oxy-core / src / repo / config.rs
10.3 KiB · Rust · last changed Revsets: oxy log -r selects changes by set algebra over history 1 month ago source blame history raw
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
    /// `core.sign`: sign every change at commit (`true`) or leave signing to
17
    /// push-time seals (`false`, the spec default).
18
    pub sign_changes: bool,
19
    pub bookmark: String,
20
    /// `remote.<name>.url`.
21
    pub remotes: BTreeMap<String, String>,
22
    /// `remote.<name>.token`: bearer token for HTTP pushes.
23
    pub remote_tokens: BTreeMap<String, String>,
24
    /// `remote.<name>.ca`: PEM file of extra roots to trust for that remote.
25
    pub remote_cas: BTreeMap<String, String>,
26
    /// `core.op-retention`: days for which operations keep their states reachable (spec §12.1, §21).
27
    pub op_retention_days: u64,
28
    /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21).
29
    pub gc_grace_days: u64,
30
    /// `core.checkpoint-interval`: changes between automatic checkpoints (spec §11).
31
    pub checkpoint_interval: u64,
32
    /// `filter.max-blob`: blobs larger than this many bytes are fetched lazily (spec §17).
33
    pub max_blob: Option<u64>,
34
    /// `filter.paths`: only these subtrees are materialized (spec §17); empty means everything.
35
    pub sparse_paths: Vec<String>,
36
    /// `hooks.semantic-deps`: run the semantic-deps hook at commit (spec §20).
37
    pub semantic_deps: bool,
38
    /// `revset.<name>`: a saved revset, used as `name()`.
39
    pub revsets: BTreeMap<String, String>,
40
}
41
42
impl Config {
43
    pub(super) fn default_for_user() -> Config {
44
        let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
45
        Config {
46
            repo_layer: BTreeMap::new(),
47
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
48
            author_email: std::env::var("OXY_AUTHOR_EMAIL")
49
                .unwrap_or_else(|_| format!("{user}@localhost")),
50
            author_key: None,
51
            sign_changes: false,
52
            bookmark: "main".into(),
53
            remotes: BTreeMap::new(),
54
            remote_tokens: BTreeMap::new(),
55
            remote_cas: BTreeMap::new(),
56
            op_retention_days: 90,
57
            gc_grace_days: 14,
58
            checkpoint_interval: 256,
59
            max_blob: None,
60
            sparse_paths: Vec::new(),
61
            semantic_deps: false,
62
            revsets: BTreeMap::new(),
63
        }
64
    }
65
66
    /// Set a key in the repository layer, as `oxy config` does.
67
    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
68
        self.apply(key, value)?;
69
        if value.is_empty() {
70
            self.repo_layer.remove(key);
71
        } else {
72
            self.repo_layer.insert(key.to_string(), value.to_string());
73
        }
74
        Ok(())
75
    }
76
77
    /// Set a key in the user file without touching any repository.
78
    pub fn set_user(user_path: &Path, key: &str, value: &str) -> Result<()> {
79
        Config::default_for_user().apply(key, value)?;
80
        let mut entries = read_config_file(user_path)?;
81
        if value.is_empty() {
82
            entries.remove(key);
83
        } else {
84
            entries.insert(key.to_string(), value.to_string());
85
        }
86
        if let Some(parent) = user_path.parent() {
87
            fs::create_dir_all(parent)?;
88
        }
89
        write_config_file(user_path, &entries)
90
    }
91
92
    /// Apply a key to the effective values.
93
    pub(super) fn apply(&mut self, key: &str, value: &str) -> Result<()> {
94
        match key {
95
            "author.name" => self.author_name = value.into(),
96
            "author.email" => self.author_email = value.into(),
97
            "author.key" => {
98
                self.author_key = if value.is_empty() {
99
                    None
100
                } else {
101
                    Some(KeyId::from_hex(value).ok_or_else(|| {
102
                        RepoError::Msg("author.key must be a 64-character hex KeyId".into())
103
                    })?)
104
                }
105
            }
106
            "core.sign" => {
107
                self.sign_changes = match value {
108
                    "change" => true,
109
                    "seal" => false,
110
                    _ => return fail("core.sign must be \"change\" or \"seal\""),
111
                }
112
            }
113
            "workspace.bookmark" => self.bookmark = value.into(),
114
            // Read by the credential store from the user file.
115
            "auth.helper" => {}
116
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
117
            "core.gc-grace" => self.gc_grace_days = parse_days(value)?,
118
            "filter.max-blob" => {
119
                self.max_blob = if value.is_empty() {
120
                    None
121
                } else {
122
                    Some(parse_size(value)?)
123
                }
124
            }
125
            "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"),
126
            "filter.paths" => {
127
                self.sparse_paths = value
128
                    .split(',')
129
                    .map(|p| {
130
                        p.trim()
131
                            .trim_start_matches("./")
132
                            .trim_matches('/')
133
                            .to_string()
134
                    })
135
                    .filter(|p| !p.is_empty())
136
                    .collect();
137
            }
138
            "core.checkpoint-interval" => {
139
                self.checkpoint_interval = value
140
                    .parse()
141
                    .map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?;
142
            }
143
            k if k.starts_with("remote.") => {
144
                let rest = &k["remote.".len()..];
145
                let Some((name, field)) = rest.rsplit_once('.') else {
146
                    return fail(format!("unknown configuration key {key:?}"));
147
                };
148
                if name.is_empty() || name.contains('/') {
149
                    return fail(format!("invalid remote name in {key:?}"));
150
                }
151
                let map = match field {
152
                    "url" => &mut self.remotes,
153
                    "token" => &mut self.remote_tokens,
154
                    "ca" => &mut self.remote_cas,
155
                    _ => return fail(format!("unknown configuration key {key:?}")),
156
                };
157
                if value.is_empty() {
158
                    map.remove(name);
159
                    if field == "url" {
160
                        self.remote_tokens.remove(name);
161
                        self.remote_cas.remove(name);
162
                    }
163
                } else {
164
                    map.insert(name.to_string(), value.into());
165
                }
166
            }
167
            k if k.starts_with("revset.") => {
168
                let name = &k["revset.".len()..];
169
                if name.is_empty()
170
                    || !name
171
                        .chars()
172
                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
173
                {
174
                    return fail(format!(
175
                        "a revset's name is letters, digits, _ and -: {key:?}"
176
                    ));
177
                }
178
                if value.is_empty() {
179
                    self.revsets.remove(name);
180
                } else {
181
                    crate::revset::parse(value)
182
                        .map_err(|e| RepoError::Msg(format!("{key} = {value:?}: {e}")))?;
183
                    self.revsets.insert(name.to_string(), value.into());
184
                }
185
            }
186
            _ => return fail(format!("unknown configuration key {key:?}")),
187
        }
188
        Ok(())
189
    }
190
191
    /// Defaults, then the user file if it exists, then the repository file.
192
    pub(super) fn load(user_path: Option<&Path>, repo_path: &Path) -> Result<Config> {
193
        let mut c = Config::default_for_user();
194
        if let Some(u) = user_path {
195
            for (k, v) in read_config_file(u)? {
196
                let _ = c.apply(&k, &v);
197
            }
198
        }
199
        for (k, v) in read_config_file(repo_path)? {
200
            if c.apply(&k, &v).is_ok() {
201
                c.repo_layer.insert(k, v);
202
            }
203
        }
204
        Ok(c)
205
    }
206
207
    pub(super) fn save(&self, path: &Path) -> Result<()> {
208
        write_config_file(path, &self.repo_layer)
209
    }
210
211
    /// Keys explicitly set for this repository.
212
    pub fn repo_layer(&self) -> &BTreeMap<String, String> {
213
        &self.repo_layer
214
    }
215
}
216
217
/// A byte count with an optional `K`, `M`, or `G` suffix (binary units).
218
pub fn parse_size(v: &str) -> Result<u64> {
219
    let v = v.trim();
220
    let (num, mult) = match v.chars().last() {
221
        Some('K' | 'k') => (&v[..v.len() - 1], 1u64 << 10),
222
        Some('M' | 'm') => (&v[..v.len() - 1], 1u64 << 20),
223
        Some('G' | 'g') => (&v[..v.len() - 1], 1u64 << 30),
224
        _ => (v, 1),
225
    };
226
    let n: u64 = num
227
        .trim()
228
        .parse()
229
        .map_err(|_| RepoError::Msg(format!("expected a size like 1M, got {v:?}")))?;
230
    Ok(n * mult)
231
}
232
233
/// A count of days, optionally suffixed `d`.
234
fn parse_days(v: &str) -> Result<u64> {
235
    v.trim_end_matches('d')
236
        .parse()
237
        .map_err(|_| RepoError::Msg(format!("expected a number of days, got {v:?}")))
238
}
239
240
/// Read `key = "value"` lines; a missing file is empty.
241
pub fn read_config_file(path: &Path) -> Result<BTreeMap<String, String>> {
242
    let text = match fs::read_to_string(path) {
243
        Ok(t) => t,
244
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
245
        Err(e) => return Err(e.into()),
246
    };
247
    let mut out = BTreeMap::new();
248
    for line in text.lines() {
249
        let line = line.trim();
250
        if line.is_empty() || line.starts_with('#') {
251
            continue;
252
        }
253
        let Some((k, v)) = line.split_once('=') else {
254
            continue;
255
        };
256
        out.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string());
257
    }
258
    Ok(out)
259
}
260
261
fn write_config_file(path: &Path, entries: &BTreeMap<String, String>) -> Result<()> {
262
    let text: String = entries
263
        .iter()
264
        .map(|(k, v)| format!("{k} = \"{v}\"\n"))
265
        .collect();
266
    write_atomic(path, text.as_bytes())
267
}