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