OXYGENHUB
# 7a716eb94dee oxygen / crates / oxy-core / src / repo / config.rs
7.4 KiB · Rust · last changed Checkpoints: serialized states per bookmark, automatic every core.checkpoint-interval changes, oxy checkpoint, checkpoint-accelerated load_state; states keep change metadata only 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
}
33
34
impl Config {
35
    pub(super) fn default_for_user() -> Config {
36
        let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
37
        Config {
38
            repo_layer: BTreeMap::new(),
39
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
40
            author_email: std::env::var("OXY_AUTHOR_EMAIL").unwrap_or_else(|_| format!("{user}@localhost")),
41
            author_key: None,
42
            sign_changes: false,
43
            bookmark: "main".into(),
44
            remotes: BTreeMap::new(),
45
            remote_tokens: BTreeMap::new(),
46
            remote_cas: BTreeMap::new(),
47
            op_retention_days: 90,
48
            gc_grace_days: 14,
49
            checkpoint_interval: 256,
50
        }
51
    }
52
53
    /// Set a key in the repository layer, as `oxy config` does.
54
    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
55
        self.apply(key, value)?;
56
        if value.is_empty() {
57
            self.repo_layer.remove(key);
58
        } else {
59
            self.repo_layer.insert(key.to_string(), value.to_string());
60
        }
61
        Ok(())
62
    }
63
64
    /// Set a key in the user file without touching any repository.
65
    pub fn set_user(user_path: &Path, key: &str, value: &str) -> Result<()> {
66
        Config::default_for_user().apply(key, value)?;
67
        let mut entries = read_config_file(user_path)?;
68
        if value.is_empty() {
69
            entries.remove(key);
70
        } else {
71
            entries.insert(key.to_string(), value.to_string());
72
        }
73
        if let Some(parent) = user_path.parent() {
74
            fs::create_dir_all(parent)?;
75
        }
76
        write_config_file(user_path, &entries)
77
    }
78
79
    /// Apply a key to the effective values.
80
    pub(super) fn apply(&mut self, key: &str, value: &str) -> Result<()> {
81
        match key {
82
            "author.name" => self.author_name = value.into(),
83
            "author.email" => self.author_email = value.into(),
84
            "author.key" => {
85
                self.author_key = if value.is_empty() {
86
                    None
87
                } else {
88
                    Some(KeyId::from_hex(value).ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?)
89
                }
90
            }
91
            "core.sign" => {
92
                self.sign_changes = match value {
93
                    "change" => true,
94
                    "seal" => false,
95
                    _ => return fail("core.sign must be \"change\" or \"seal\""),
96
                }
97
            }
98
            "workspace.bookmark" => self.bookmark = value.into(),
99
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
100
            "core.gc-grace" => self.gc_grace_days = parse_days(value)?,
101
            "core.checkpoint-interval" => {
102
                self.checkpoint_interval = value.parse().map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?;
103
            }
104
            k if k.starts_with("remote.") => {
105
                let rest = &k["remote.".len()..];
106
                let Some((name, field)) = rest.rsplit_once('.') else {
107
                    return fail(format!("unknown configuration key {key:?}"));
108
                };
109
                if name.is_empty() || name.contains('/') {
110
                    return fail(format!("invalid remote name in {key:?}"));
111
                }
112
                let map = match field {
113
                    "url" => &mut self.remotes,
114
                    "token" => &mut self.remote_tokens,
115
                    "ca" => &mut self.remote_cas,
116
                    _ => return fail(format!("unknown configuration key {key:?}")),
117
                };
118
                if value.is_empty() {
119
                    map.remove(name);
120
                    if field == "url" {
121
                        self.remote_tokens.remove(name);
122
                        self.remote_cas.remove(name);
123
                    }
124
                } else {
125
                    map.insert(name.to_string(), value.into());
126
                }
127
            }
128
            _ => return fail(format!("unknown configuration key {key:?}")),
129
        }
130
        Ok(())
131
    }
132
133
    /// Defaults, then the user file if it exists, then the repository file.
134
    pub(super) fn load(user_path: Option<&Path>, repo_path: &Path) -> Result<Config> {
135
        let mut c = Config::default_for_user();
136
        if let Some(u) = user_path {
137
            for (k, v) in read_config_file(u)? {
138
                let _ = c.apply(&k, &v);
139
            }
140
        }
141
        for (k, v) in read_config_file(repo_path)? {
142
            if c.apply(&k, &v).is_ok() {
143
                c.repo_layer.insert(k, v);
144
            }
145
        }
146
        Ok(c)
147
    }
148
149
    pub(super) fn save(&self, path: &Path) -> Result<()> {
150
        write_config_file(path, &self.repo_layer)
151
    }
152
153
    /// Keys explicitly set for this repository.
154
    pub fn repo_layer(&self) -> &BTreeMap<String, String> {
155
        &self.repo_layer
156
    }
157
}
158
159
/// A count of days, optionally suffixed `d`.
160
fn parse_days(v: &str) -> Result<u64> {
161
    v.trim_end_matches('d').parse().map_err(|_| RepoError::Msg(format!("expected a number of days, got {v:?}")))
162
}
163
164
/// Read `key = "value"` lines; a missing file is empty.
165
pub fn read_config_file(path: &Path) -> Result<BTreeMap<String, String>> {
166
    let text = match fs::read_to_string(path) {
167
        Ok(t) => t,
168
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
169
        Err(e) => return Err(e.into()),
170
    };
171
    let mut out = BTreeMap::new();
172
    for line in text.lines() {
173
        let line = line.trim();
174
        if line.is_empty() || line.starts_with('#') {
175
            continue;
176
        }
177
        let Some((k, v)) = line.split_once('=') else { continue };
178
        out.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string());
179
    }
180
    Ok(out)
181
}
182
183
fn write_config_file(path: &Path, entries: &BTreeMap<String, String>) -> Result<()> {
184
    let text: String = entries.iter().map(|(k, v)| format!("{k} = \"{v}\"\n")).collect();
185
    write_atomic(path, text.as_bytes())
186
}
187