Skip to main content

taba_cli/profile/
mod.rs

1//! The connection profiles and the `config.yml` file (doc 19b, section 18.11.2).
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8use crate::yaml::{self, Node};
9
10/// The fields of a profile, in the order they are written and shown.
11pub const FIELDS: [&str; 9] = [
12    "url",
13    "ops-url",
14    "token-file",
15    "token",
16    "ca-file",
17    "output",
18    "connect-timeout",
19    "timeout",
20    "max-attempts",
21];
22
23pub const DEFAULT_URL: &str = "http://127.0.0.1:8080";
24pub const OUTPUTS: [&str; 3] = ["json", "table", "raw"];
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Os {
28    Windows,
29    Linux,
30    MacOs,
31}
32
33impl Os {
34    pub fn current() -> Os {
35        if cfg!(windows) {
36            Os::Windows
37        } else if cfg!(target_os = "macos") {
38            Os::MacOs
39        } else {
40            Os::Linux
41        }
42    }
43}
44
45/// A profile: only the fields the person set.
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct Profile {
48    values: BTreeMap<String, String>,
49}
50
51impl Profile {
52    pub fn get(&self, field: &str) -> Option<&str> {
53        self.values.get(field).map(String::as_str)
54    }
55
56    /// Sets a field, after validating it.
57    pub fn set(&mut self, field: &str, value: &str) -> Result<(), String> {
58        validate_field(field, value)?;
59        self.values.insert(field.to_owned(), value.to_owned());
60        Ok(())
61    }
62
63    pub fn unset(&mut self, field: &str) {
64        self.values.remove(field);
65    }
66
67    /// The fields set, in canonical order.
68    pub fn entries(&self) -> Vec<(&'static str, &str)> {
69        FIELDS
70            .iter()
71            .filter_map(|f| self.values.get(*f).map(|v| (*f, v.as_str())))
72            .collect()
73    }
74}
75
76/// Checks a field's value without calling the server or touching files.
77pub fn validate_field(field: &str, value: &str) -> Result<(), String> {
78    if !FIELDS.contains(&field) {
79        return Err(format!(
80            "`{field}` is not a profile field ({})",
81            FIELDS.join(", ")
82        ));
83    }
84    let bad = |m: &str| Err(format!("{field}: {m}"));
85    match field {
86        "url" | "ops-url" => taba_client::http::Url::parse(value)
87            .map(|_| ())
88            .or_else(|e| bad(&e)),
89        "output" if !OUTPUTS.contains(&value) => bad("use json, table or raw"),
90        "connect-timeout" => number(value, 1, 300)
91            .map(|_| ())
92            .or_else(|_| bad("use a number from 1 to 300 seconds")),
93        "timeout" => number(value, 1, 3600)
94            .map(|_| ())
95            .or_else(|_| bad("use a number from 1 to 3600 seconds")),
96        "max-attempts" => number(value, 1, 10)
97            .map(|_| ())
98            .or_else(|_| bad("use a number from 1 to 10")),
99        "token-file" | "token" | "ca-file" if value.trim().is_empty() => {
100            bad("the value cannot be empty")
101        }
102        _ => Ok(()),
103    }
104}
105
106fn number(value: &str, min: u64, max: u64) -> Result<u64, ()> {
107    value
108        .parse::<u64>()
109        .ok()
110        .filter(|n| (min..=max).contains(n))
111        .ok_or(())
112}
113
114/// A profile's name: ASCII letters, digits, `-` and `_`, from 1 to 64 characters.
115pub fn validate_name(name: &str) -> Result<(), String> {
116    let ok = !name.is_empty()
117        && name.len() <= 64
118        && name
119            .chars()
120            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
121    if ok {
122        Ok(())
123    } else {
124        Err(format!(
125            "the profile name `{name}` is invalid: use 1 to 64 letters, digits, `-` or `_`"
126        ))
127    }
128}
129
130/// The whole file: the default profile and the profiles, in the order they were created.
131#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct ConfigFile {
133    pub default: Option<String>,
134    pub profiles: Vec<(String, Profile)>,
135    /// `ui.language`: the interface language the person chose (doc 34, section 8).
136    pub language: Option<String>,
137    /// `history.retention` and `history.interval` of the interface, as written (`1h`, `5s`).
138    pub history_retention: Option<String>,
139    pub history_interval: Option<String>,
140}
141
142impl ConfigFile {
143    pub fn get(&self, name: &str) -> Option<&Profile> {
144        self.profiles
145            .iter()
146            .find(|(n, _)| n == name)
147            .map(|(_, p)| p)
148    }
149
150    pub fn get_mut(&mut self, name: &str) -> Option<&mut Profile> {
151        self.profiles
152            .iter_mut()
153            .find(|(n, _)| n == name)
154            .map(|(_, p)| p)
155    }
156
157    pub fn names(&self) -> Vec<&str> {
158        self.profiles.iter().map(|(n, _)| n.as_str()).collect()
159    }
160
161    /// Creates a profile. The file's first profile becomes the default. Error if the name already exists.
162    pub fn add(&mut self, name: &str, profile: Profile) -> Result<(), String> {
163        validate_name(name)?;
164        if self.get(name).is_some() {
165            return Err(format!(
166                "the profile `{name}` already exists (use `profile set`)"
167            ));
168        }
169        self.profiles.push((name.to_owned(), profile));
170        if self.profiles.len() == 1 {
171            self.default = Some(name.to_owned());
172        }
173        Ok(())
174    }
175
176    /// Removes a profile; if it was the default, the file is left with no default.
177    pub fn remove(&mut self, name: &str) -> Result<(), String> {
178        let before = self.profiles.len();
179        self.profiles.retain(|(n, _)| n != name);
180        if self.profiles.len() == before {
181            return Err(format!("the profile `{name}` does not exist"));
182        }
183        if self.default.as_deref() == Some(name) {
184            self.default = None;
185        }
186        Ok(())
187    }
188
189    pub fn set_default(&mut self, name: &str) -> Result<(), String> {
190        if self.get(name).is_none() {
191            return Err(format!("the profile `{name}` does not exist"));
192        }
193        self.default = Some(name.to_owned());
194        Ok(())
195    }
196
197    pub fn parse(text: &str) -> Result<ConfigFile, String> {
198        let map = yaml::parse(text).map_err(|e| e.to_string())?;
199        let mut file = ConfigFile::default();
200        for (key, node) in map {
201            match (key.as_str(), node) {
202                ("default", Node::Text(name)) => {
203                    if !name.is_empty() {
204                        file.default = Some(name);
205                    }
206                }
207                ("profiles", Node::Map(profiles)) => {
208                    for (name, node) in profiles {
209                        validate_name(&name)?;
210                        let Node::Map(fields) = node else {
211                            return Err(format!("the profile `{name}` must be a map of fields"));
212                        };
213                        let mut profile = Profile::default();
214                        for (field, value) in fields {
215                            let Node::Text(value) = value else {
216                                return Err(format!("`{name}.{field}` must be a text"));
217                            };
218                            profile
219                                .set(&field, &value)
220                                .map_err(|e| format!("profile `{name}`: {e}"))?;
221                        }
222                        file.profiles.push((name, profile));
223                    }
224                }
225                ("ui", Node::Map(fields)) => {
226                    for (field, value) in fields {
227                        match (field.as_str(), value) {
228                            ("language", Node::Text(text)) if !text.trim().is_empty() => {
229                                file.language = Some(text);
230                            }
231                            _ => return Err(format!("`ui.{field}` is not valid (use `language`)")),
232                        }
233                    }
234                }
235                ("history", Node::Map(fields)) => {
236                    for (field, value) in fields {
237                        let Node::Text(text) = value else {
238                            return Err(format!("`history.{field}` must be a text"));
239                        };
240                        crate::settings::parse_span(&text)
241                            .map_err(|e| format!("`history.{field}`: {e}"))?;
242                        match field.as_str() {
243                            "retention" => file.history_retention = Some(text),
244                            "interval" => file.history_interval = Some(text),
245                            _ => {
246                                return Err(format!(
247                                "`history.{field}` is not valid (use `retention` and `interval`)"
248                            ))
249                            }
250                        }
251                    }
252                }
253                ("default" | "profiles" | "ui" | "history", _) => {
254                    return Err(format!("`{key}` has the wrong type"))
255                }
256                _ => {
257                    return Err(format!(
258                        "unknown key `{key}` (use `default`, `ui`, `history` and `profiles`)"
259                    ))
260                }
261            }
262        }
263        if let Some(default) = &file.default {
264            if file.get(default).is_none() {
265                return Err(format!(
266                    "the default profile `{default}` does not exist in `profiles`"
267                ));
268            }
269        }
270        Ok(file)
271    }
272
273    pub fn render(&self) -> String {
274        let mut map = Vec::new();
275        if let Some(default) = &self.default {
276            map.push(("default".to_owned(), Node::Text(default.clone())));
277        }
278        if let Some(language) = &self.language {
279            map.push((
280                "ui".to_owned(),
281                Node::Map(vec![("language".to_owned(), Node::Text(language.clone()))]),
282            ));
283        }
284        let history: Vec<(String, Node)> = [
285            ("retention", &self.history_retention),
286            ("interval", &self.history_interval),
287        ]
288        .into_iter()
289        .filter_map(|(k, v)| v.as_ref().map(|v| (k.to_owned(), Node::Text(v.clone()))))
290        .collect();
291        if !history.is_empty() {
292            map.push(("history".to_owned(), Node::Map(history)));
293        }
294        let profiles = self
295            .profiles
296            .iter()
297            .map(|(name, profile)| {
298                let fields = profile
299                    .entries()
300                    .into_iter()
301                    .map(|(k, v)| (k.to_owned(), Node::Text(v.to_owned())))
302                    .collect();
303                (name.clone(), Node::Map(fields))
304            })
305            .collect();
306        map.push(("profiles".to_owned(), Node::Map(profiles)));
307        format!(
308            "# taba-cli profiles. The file is rewritten by `taba-cli profile`; comments are not kept.\n{}",
309            yaml::render(&map)
310        )
311    }
312}
313
314/// The path of `config.yml`: `TABA_CLI_CONFIG`, or the user's configuration folder on the system.
315pub fn config_path(os: Os, env: &dyn Fn(&str) -> Option<String>) -> Result<PathBuf, String> {
316    if let Some(path) = env("TABA_CLI_CONFIG").filter(|p| !p.is_empty()) {
317        return Ok(PathBuf::from(path));
318    }
319    let missing = |var: &str| {
320        format!("could not find the user's configuration folder (the variable {var} does not exist); use TABA_CLI_CONFIG")
321    };
322    let non_empty = |name: &str| env(name).filter(|v| !v.is_empty());
323    let dir = match os {
324        Os::Windows => PathBuf::from(non_empty("APPDATA").ok_or_else(|| missing("APPDATA"))?),
325        Os::MacOs => PathBuf::from(non_empty("HOME").ok_or_else(|| missing("HOME"))?)
326            .join("Library")
327            .join("Application Support"),
328        Os::Linux => match non_empty("XDG_CONFIG_HOME") {
329            Some(xdg) => PathBuf::from(xdg),
330            None => {
331                PathBuf::from(non_empty("HOME").ok_or_else(|| missing("HOME"))?).join(".config")
332            }
333        },
334    };
335    Ok(dir.join("taba").join("config.yml"))
336}
337
338/// Reads the file; if it does not exist, it is an empty file. Unreadable or invalid: the error gives the path.
339pub fn load(path: &Path) -> Result<ConfigFile, String> {
340    match fs::read_to_string(path) {
341        Ok(text) => ConfigFile::parse(&text).map_err(|e| format!("{}: {e}", path.display())),
342        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ConfigFile::default()),
343        Err(e) => Err(format!("could not read {}: {e}", path.display())),
344    }
345}
346
347/// Rewrites the whole file atomically: writes a temporary file next to it and renames it
348/// over the original. On Unix the file is created with `0600` permission.
349pub fn save(path: &Path, file: &ConfigFile) -> Result<(), String> {
350    let fail = |what: &str, e: std::io::Error| format!("could not {what} {}: {e}", path.display());
351    let dir = path
352        .parent()
353        .filter(|p| !p.as_os_str().is_empty())
354        .unwrap_or(Path::new("."));
355    fs::create_dir_all(dir).map_err(|e| fail("create the folder", e))?;
356    let tmp = dir.join(format!(
357        ".{}.tmp-{}",
358        path.file_name()
359            .and_then(|n| n.to_str())
360            .unwrap_or("config.yml"),
361        std::process::id()
362    ));
363    let write = || -> std::io::Result<()> {
364        let mut options = fs::OpenOptions::new();
365        options.write(true).create(true).truncate(true);
366        #[cfg(unix)]
367        {
368            use std::os::unix::fs::OpenOptionsExt;
369            options.mode(0o600);
370        }
371        let mut out = options.open(&tmp)?;
372        out.write_all(file.render().as_bytes())?;
373        out.sync_all()?;
374        drop(out);
375        fs::rename(&tmp, path)
376    };
377    write().map_err(|e| {
378        let _ = fs::remove_file(&tmp);
379        fail("write", e)
380    })
381}
382
383#[cfg(test)]
384mod tests;