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 number(&self, field: &str) -> u64 {
146 self.get(field).and_then(|v| v.parse().ok()).unwrap_or(0)
147 }
148
149 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 pub fn settings(&self) -> impl Iterator<Item = (&'static str, &Setting)> {
167 self.settings.iter().map(|(f, s)| (*f, s))
168 }
169}
170
171pub 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;