Skip to main content

yt/config/
file.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use miette::IntoDiagnostic as _;
5use serde::Deserialize;
6use toml_edit::{DocumentMut, Item, Table, value};
7
8use crate::error::{Result, YtError};
9use crate::instance::InstanceUrl;
10
11#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "lowercase")]
13pub enum AuthKind {
14    Oauth,
15    Token,
16}
17
18impl AuthKind {
19    pub fn as_str(self) -> &'static str {
20        match self {
21            Self::Oauth => "oauth",
22            Self::Token => "token",
23        }
24    }
25}
26
27#[derive(Clone, Debug, Deserialize, PartialEq)]
28pub struct Profile {
29    pub url: String,
30    pub login: Option<String>,
31    pub auth: AuthKind,
32    pub project: Option<String>,
33    #[serde(default)]
34    pub default_for_url: bool,
35    pub oauth_client_id: Option<String>,
36    pub ca_file: Option<PathBuf>,
37    #[serde(default)]
38    pub revocable: bool,
39}
40
41#[derive(Debug, Default, Deserialize)]
42pub struct GlobalConfig {
43    pub default_profile: Option<String>,
44    #[serde(default)]
45    pub profiles: BTreeMap<String, Profile>,
46}
47
48/// Reads the global config and writes changes without losing the user's comments or formatting.
49pub struct ConfigStore {
50    path: PathBuf,
51}
52
53impl ConfigStore {
54    pub fn new(path: PathBuf) -> Self {
55        Self { path }
56    }
57
58    pub fn path(&self) -> &Path {
59        &self.path
60    }
61
62    pub fn load(&self) -> Result<GlobalConfig> {
63        match std::fs::read_to_string(&self.path) {
64            Ok(text) => toml::from_str(&text).map_err(|error| self.invalid(error.to_string())),
65            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
66                Ok(GlobalConfig::default())
67            }
68            Err(error) => Err(self.invalid(error.to_string())),
69        }
70    }
71
72    pub fn set_profile(&self, name: &str, profile: &Profile) -> Result<()> {
73        self.edit(|doc| {
74            let table = self.profile_table(doc, name)?;
75            table["url"] = value(profile.url.as_str());
76            set_optional(table, "login", profile.login.as_deref());
77            table["auth"] = value(profile.auth.as_str());
78            set_optional(table, "project", profile.project.as_deref());
79            set_flag(table, "default_for_url", profile.default_for_url);
80            set_optional(table, "oauth_client_id", profile.oauth_client_id.as_deref());
81            set_optional(
82                table,
83                "ca_file",
84                profile.ca_file.as_deref().and_then(Path::to_str),
85            );
86            set_flag(table, "revocable", profile.revocable);
87            Ok(())
88        })
89    }
90
91    pub fn remove_profile(&self, name: &str) -> Result<()> {
92        self.edit(|doc| {
93            if let Some(profiles) = doc.get_mut("profiles").and_then(Item::as_table_mut) {
94                profiles.remove(name);
95            }
96            if doc.get("default_profile").and_then(Item::as_str) == Some(name) {
97                doc.remove("default_profile");
98            }
99            Ok(())
100        })
101    }
102
103    /// Makes `name` the default profile, and the default among profiles for the same URL.
104    pub fn set_default_profile(&self, name: &str) -> Result<()> {
105        let config = self.load()?;
106        let target = config
107            .profiles
108            .get(name)
109            .ok_or_else(|| self.invalid(format!("there is no profile '{name}'")))?;
110        let url = InstanceUrl::parse(&target.url).ok();
111        let same_url: Vec<String> = config
112            .profiles
113            .iter()
114            .filter(|(other, profile)| {
115                *other != name && InstanceUrl::parse(&profile.url).ok() == url
116            })
117            .map(|(other, _)| other.clone())
118            .collect();
119        self.edit(|doc| {
120            doc["default_profile"] = value(name);
121            for other in &same_url {
122                self.profile_table(doc, other)?.remove("default_for_url");
123            }
124            self.profile_table(doc, name)?["default_for_url"] = value(true);
125            Ok(())
126        })
127    }
128
129    pub fn set_setting(&self, key: &str, setting: toml_edit::Value) -> Result<()> {
130        self.edit(|doc| {
131            let settings = doc
132                .entry("settings")
133                .or_insert(Item::Table(Table::new()))
134                .as_table_mut()
135                .ok_or_else(|| self.invalid("[settings] is not a table".into()))?;
136            settings[key] = Item::Value(setting);
137            Ok(())
138        })
139    }
140
141    fn profile_table<'d>(&self, doc: &'d mut DocumentMut, name: &str) -> Result<&'d mut Table> {
142        let profiles = doc
143            .entry("profiles")
144            .or_insert(Item::Table(Table::new()))
145            .as_table_mut()
146            .ok_or_else(|| self.invalid("[profiles] is not a table".into()))?;
147        profiles.set_implicit(true);
148        profiles
149            .entry(name)
150            .or_insert(Item::Table(Table::new()))
151            .as_table_mut()
152            .ok_or_else(|| self.invalid(format!("[profiles.{name}] is not a table")))
153    }
154
155    fn edit(&self, change: impl FnOnce(&mut DocumentMut) -> Result<()>) -> Result<()> {
156        let text = match std::fs::read_to_string(&self.path) {
157            Ok(text) => text,
158            Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
159            Err(error) => return Err(self.invalid(error.to_string())),
160        };
161        let mut doc: DocumentMut = text
162            .parse()
163            .map_err(|error: toml_edit::TomlError| self.invalid(error.to_string()))?;
164        change(&mut doc)?;
165        if let Some(parent) = self.path.parent() {
166            std::fs::create_dir_all(parent).into_diagnostic()?;
167        }
168        // Rename onto the link's target so a symlinked config (dotfile managers) stays a link.
169        let target = std::fs::canonicalize(&self.path).unwrap_or_else(|_| self.path.clone());
170        let name = target
171            .file_name()
172            .map(|name| name.to_string_lossy().into_owned())
173            .unwrap_or_default();
174        let temporary = target.with_file_name(format!(".{name}.{}.tmp", std::process::id()));
175        std::fs::write(&temporary, doc.to_string()).into_diagnostic()?;
176        std::fs::rename(&temporary, &target).into_diagnostic()
177    }
178
179    fn invalid(&self, message: String) -> miette::Report {
180        YtError::Config {
181            path: self.path.display().to_string(),
182            message,
183        }
184        .into()
185    }
186}
187
188fn set_optional(table: &mut Table, key: &str, setting: Option<&str>) {
189    match setting {
190        Some(setting) => table[key] = value(setting),
191        None => {
192            table.remove(key);
193        }
194    }
195}
196
197fn set_flag(table: &mut Table, key: &str, flag: bool) {
198    if flag {
199        table[key] = value(true);
200    } else {
201        table.remove(key);
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn store_with(text: &str) -> (tempfile::TempDir, ConfigStore) {
210        let dir = tempfile::tempdir().unwrap();
211        let path = dir.path().join("yt").join("config.toml");
212        if !text.is_empty() {
213            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
214            std::fs::write(&path, text).unwrap();
215        }
216        (dir, ConfigStore::new(path))
217    }
218
219    fn profile(url: &str) -> Profile {
220        Profile {
221            url: url.into(),
222            login: Some("jane".into()),
223            auth: AuthKind::Oauth,
224            project: None,
225            default_for_url: false,
226            oauth_client_id: Some("yt-cli".into()),
227            ca_file: None,
228            revocable: false,
229        }
230    }
231
232    #[test]
233    fn a_missing_file_is_an_empty_config() {
234        let (_dir, store) = store_with("");
235        let config = store.load().unwrap();
236        assert!(config.profiles.is_empty() && config.default_profile.is_none());
237    }
238
239    #[test]
240    fn set_profile_creates_the_file_and_round_trips() {
241        let (_dir, store) = store_with("");
242        store
243            .set_profile("acme", &profile("https://acme.youtrack.cloud"))
244            .unwrap();
245        let config = store.load().unwrap();
246        assert_eq!(
247            config.profiles["acme"],
248            profile("https://acme.youtrack.cloud")
249        );
250    }
251
252    #[test]
253    fn writes_keep_comments_and_unknown_keys() {
254        let (_dir, store) = store_with(
255            "# my yt config\n[profiles.acme] # work\nurl = \"https://acme.youtrack.cloud\"\nauth = \"token\"\ncolor_hint = \"keep me\"\n",
256        );
257        store
258            .set_profile("acme", &profile("https://acme.youtrack.cloud"))
259            .unwrap();
260        let text = std::fs::read_to_string(store.path()).unwrap();
261        assert!(text.starts_with("# my yt config\n"), "{text}");
262        assert!(text.contains("# work"), "{text}");
263        assert!(text.contains("color_hint = \"keep me\""), "{text}");
264        assert!(text.contains("auth = \"oauth\""), "{text}");
265    }
266
267    #[test]
268    fn default_profile_and_default_for_url() {
269        let (_dir, store) = store_with("");
270        store
271            .set_profile("a", &profile("https://acme.youtrack.cloud"))
272            .unwrap();
273        store
274            .set_profile("b", &profile("https://acme.youtrack.cloud/"))
275            .unwrap();
276        store.set_default_profile("a").unwrap();
277        store.set_default_profile("b").unwrap();
278        let config = store.load().unwrap();
279        assert_eq!(config.default_profile.as_deref(), Some("b"));
280        assert!(config.profiles["b"].default_for_url);
281        assert!(!config.profiles["a"].default_for_url);
282    }
283
284    #[test]
285    fn remove_profile_clears_the_default() {
286        let (_dir, store) = store_with("");
287        store
288            .set_profile("a", &profile("https://acme.youtrack.cloud"))
289            .unwrap();
290        store.set_default_profile("a").unwrap();
291        store.remove_profile("a").unwrap();
292        let config = store.load().unwrap();
293        assert!(config.profiles.is_empty() && config.default_profile.is_none());
294    }
295
296    #[test]
297    fn set_setting_writes_the_settings_table() {
298        let (_dir, store) = store_with("");
299        store.set_setting("http_retries", 5.into()).unwrap();
300        let text = std::fs::read_to_string(store.path()).unwrap();
301        assert!(
302            text.contains("[settings]") && text.contains("http_retries = 5"),
303            "{text}"
304        );
305    }
306
307    #[test]
308    fn invalid_toml_is_a_config_error() {
309        let (_dir, store) = store_with("[profiles\n");
310        let error = store.load().unwrap_err();
311        assert!(matches!(
312            error.downcast_ref::<YtError>(),
313            Some(YtError::Config { .. })
314        ));
315    }
316
317    #[test]
318    fn writes_go_through_a_symlinked_config() {
319        let dir = tempfile::tempdir().unwrap();
320        let dotfiles = dir.path().join("dotfiles").join("yt.toml");
321        std::fs::create_dir_all(dotfiles.parent().unwrap()).unwrap();
322        std::fs::write(&dotfiles, "# managed by dotfiles\n").unwrap();
323        let link = dir.path().join("config.toml");
324        std::os::unix::fs::symlink(&dotfiles, &link).unwrap();
325        ConfigStore::new(link.clone())
326            .set_profile("acme", &profile("https://acme.youtrack.cloud"))
327            .unwrap();
328        assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
329        let text = std::fs::read_to_string(&dotfiles).unwrap();
330        assert!(
331            text.contains("# managed by dotfiles") && text.contains("[profiles.acme]"),
332            "{text}"
333        );
334    }
335}