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