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
48pub 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 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 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}