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