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    pub fn number(&self, field: &str) -> u64 {
146        self.get(field).and_then(|v| v.parse().ok()).unwrap_or(0)
147    }
148
149    /// Which of the two applies when there is both `token` and `token-file`: the one of higher precedence (variable, then
150    /// profile) and, at the same level, the `token-file`.
151    pub fn token_source(&self) -> TokenSource<'_> {
152        let rank = |s: &Setting| match (&s.value, &s.source) {
153            (None, _) => 0,
154            (_, Source::Variable(_)) => 2,
155            (_, _) => 1,
156        };
157        let (file, text) = (self.setting("token-file"), self.setting("token"));
158        match (rank(file), rank(text)) {
159            (0, 0) => TokenSource::None,
160            (f, t) if f >= t => TokenSource::File(file.value.as_deref().unwrap_or_default()),
161            _ => TokenSource::Text(text.value.as_deref().unwrap_or_default()),
162        }
163    }
164
165    /// All the fields, for `config show`.
166    pub fn settings(&self) -> impl Iterator<Item = (&'static str, &Setting)> {
167        self.settings.iter().map(|(f, s)| (*f, s))
168    }
169}
170
171/// A length of time as `90`, `90s`, `5m` or `1h`, in seconds (the `[history]` settings of the interface).
172pub fn parse_span(text: &str) -> Result<u64, String> {
173    let text = text.trim();
174    let (digits, unit) = match text.chars().last() {
175        Some(c @ ('s' | 'm' | 'h')) => (&text[..text.len() - 1], c),
176        _ => (text, 's'),
177    };
178    let n: u64 = digits
179        .parse()
180        .map_err(|_| format!("`{text}` is not a time (use 90, 90s, 5m or 1h)"))?;
181    let factor = match unit {
182        'h' => 3600,
183        'm' => 60,
184        _ => 1,
185    };
186    n.checked_mul(factor)
187        .ok_or_else(|| format!("`{text}` is too long"))
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub enum TokenSource<'a> {
192    None,
193    File(&'a str),
194    Text(&'a str),
195}
196
197#[cfg(test)]
198mod tests;