Skip to main content

taba_cli/settings/
mod.rs

1//! The effective configuration of a call: `TABA_CLI_*` variable, chosen profile and built-in value,
2//! field by field, remembering where each value came from (doc 19b, section 18.11.2).
3
4use crate::profile::{self, ConfigFile, Profile, DEFAULT_URL, FIELDS};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Source {
8    /// An environment variable (`TABA_CLI_URL`).
9    Variable(String),
10    /// The profile with this name.
11    Profile(String),
12    /// The built-in value.
13    Default,
14}
15
16impl Source {
17    pub fn label(&self) -> String {
18        match self {
19            Source::Variable(v) => format!("variable {v}"),
20            Source::Profile(p) => format!("profile {p}"),
21            Source::Default => "default".to_owned(),
22        }
23    }
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Setting {
28    /// `None` when the field has no value or default (token, ca-file).
29    pub value: Option<String>,
30    pub source: Source,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Effective {
35    /// The profile in use and why it was chosen (`--profile`, `TABA_CLI_PROFILE` or the file's default).
36    pub profile: Option<(String, Source)>,
37    settings: Vec<(&'static str, Setting)>,
38}
39
40pub fn variable_name(field: &str) -> String {
41    format!("TABA_CLI_{}", field.to_ascii_uppercase().replace('-', "_"))
42}
43
44/// Chooses the profile: `--profile`, then `TABA_CLI_PROFILE`, then the file's `default`.
45fn choose_profile(
46    file: &ConfigFile,
47    flag: Option<&str>,
48    env: &dyn Fn(&str) -> Option<String>,
49) -> Result<Option<(String, Source)>, String> {
50    let from_env = env("TABA_CLI_PROFILE").filter(|v| !v.is_empty());
51    let (name, source) = match (flag, from_env, &file.default) {
52        (Some(name), _, _) => (name.to_owned(), Source::Variable("--profile".to_owned())),
53        (None, Some(name), _) => (name, Source::Variable("TABA_CLI_PROFILE".to_owned())),
54        (None, None, Some(name)) => (name.clone(), Source::Default),
55        // A file with a single profile and no default: that profile is the only possible choice.
56        (None, None, None) => match file.names().as_slice() {
57            [only] => ((*only).to_owned(), Source::Default),
58            _ => return Ok(None),
59        },
60    };
61    if file.get(&name).is_none() {
62        let known = file.names();
63        return Err(format!(
64            "the profile `{name}` does not exist{}",
65            if known.is_empty() {
66                " (there are no profiles; create one with `taba-cli profile add`)".to_owned()
67            } else {
68                format!(" (profiles: {})", known.join(", "))
69            }
70        ));
71    }
72    Ok(Some((name, source)))
73}
74
75pub fn resolve(
76    file: &ConfigFile,
77    profile_flag: Option<&str>,
78    env: &dyn Fn(&str) -> Option<String>,
79) -> Result<Effective, String> {
80    let chosen = choose_profile(file, profile_flag, env)?;
81    let empty = Profile::default();
82    let profile = chosen
83        .as_ref()
84        .and_then(|(name, _)| file.get(name))
85        .unwrap_or(&empty);
86    let profile_name = chosen.as_ref().map(|(n, _)| n.clone()).unwrap_or_default();
87
88    let mut settings = Vec::new();
89    for field in FIELDS {
90        let var = variable_name(field);
91        let setting = if let Some(value) = env(&var).filter(|v| !v.is_empty()) {
92            profile::validate_field(field, &value).map_err(|e| format!("{var}: {e}"))?;
93            Setting {
94                value: Some(value),
95                source: Source::Variable(var),
96            }
97        } else if let Some(value) = profile.get(field) {
98            Setting {
99                value: Some(value.to_owned()),
100                source: Source::Profile(profile_name.clone()),
101            }
102        } else {
103            Setting {
104                value: builtin(field).map(str::to_owned),
105                source: Source::Default,
106            }
107        };
108        settings.push((field, setting));
109    }
110    Ok(Effective {
111        profile: chosen,
112        settings,
113    })
114}
115
116fn builtin(field: &str) -> Option<&'static str> {
117    match field {
118        "url" => Some(DEFAULT_URL),
119        "output" => Some("json"),
120        "connect-timeout" => Some("5"),
121        "timeout" => Some("30"),
122        "max-attempts" => Some("3"),
123        _ => None,
124    }
125}
126
127impl Effective {
128    pub fn setting(&self, field: &str) -> &Setting {
129        &self
130            .settings
131            .iter()
132            .find(|(f, _)| *f == field)
133            .expect("known field")
134            .1
135    }
136
137    pub fn get(&self, field: &str) -> Option<&str> {
138        self.setting(field).value.as_deref()
139    }
140
141    pub fn url(&self) -> &str {
142        self.get("url").unwrap_or(DEFAULT_URL)
143    }
144
145    /// The operations API address: the `ops-url`, or the `url` itself. Without `operations.port`, the node
146    /// serves the operations routes on the client API port.
147    pub fn ops_url(&self) -> String {
148        self.get("ops-url").unwrap_or_else(|| self.url()).to_owned()
149    }
150
151    pub fn number(&self, field: &str) -> u64 {
152        self.get(field).and_then(|v| v.parse().ok()).unwrap_or(0)
153    }
154
155    /// Which of the two applies when there is both `token` and `token-file`: the one of higher precedence (variable, then
156    /// profile) and, at the same level, the `token-file`.
157    pub fn token_source(&self) -> TokenSource<'_> {
158        let rank = |s: &Setting| match (&s.value, &s.source) {
159            (None, _) => 0,
160            (_, Source::Variable(_)) => 2,
161            (_, _) => 1,
162        };
163        let (file, text) = (self.setting("token-file"), self.setting("token"));
164        match (rank(file), rank(text)) {
165            (0, 0) => TokenSource::None,
166            (f, t) if f >= t => TokenSource::File(file.value.as_deref().unwrap_or_default()),
167            _ => TokenSource::Text(text.value.as_deref().unwrap_or_default()),
168        }
169    }
170
171    /// All the fields, for `config show`.
172    pub fn settings(&self) -> impl Iterator<Item = (&'static str, &Setting)> {
173        self.settings.iter().map(|(f, s)| (*f, s))
174    }
175}
176
177/// A length of time as `90`, `90s`, `5m` or `1h`, in seconds (the `[history]` settings of the interface).
178pub fn parse_span(text: &str) -> Result<u64, String> {
179    let text = text.trim();
180    let (digits, unit) = match text.chars().last() {
181        Some(c @ ('s' | 'm' | 'h')) => (&text[..text.len() - 1], c),
182        _ => (text, 's'),
183    };
184    let n: u64 = digits
185        .parse()
186        .map_err(|_| format!("`{text}` is not a time (use 90, 90s, 5m or 1h)"))?;
187    let factor = match unit {
188        'h' => 3600,
189        'm' => 60,
190        _ => 1,
191    };
192    n.checked_mul(factor)
193        .ok_or_else(|| format!("`{text}` is too long"))
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum TokenSource<'a> {
198    None,
199    File(&'a str),
200    Text(&'a str),
201}
202
203#[cfg(test)]
204mod tests;