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