1use crate::profile::{self, ConfigFile, Profile, DEFAULT_URL, FIELDS};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Source {
8 Variable(String),
10 Profile(String),
12 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 pub value: Option<String>,
30 pub source: Source,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Effective {
35 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
44fn 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 (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 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 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 pub fn settings(&self) -> impl Iterator<Item = (&'static str, &Setting)> {
173 self.settings.iter().map(|(f, s)| (*f, s))
174 }
175}
176
177pub 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;