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