1use std::path::Path;
10
11use toml_edit::{DocumentMut, Item, Table, value};
12
13use crate::config::{OrgKind, Profile};
14
15#[derive(Debug, thiserror::Error)]
16pub enum StoreError {
17 #[error("could not read the configuration file")]
18 Read(#[source] std::io::Error),
19 #[error("could not write the configuration file")]
20 Write(#[source] std::io::Error),
21 #[error("the configuration file is not valid TOML; fix or move it first")]
22 Parse(#[from] toml_edit::TomlError),
23}
24
25pub fn upsert(
30 path: &Path,
31 account: &str,
32 description: Option<&str>,
33 profile: Option<(&str, &Profile)>,
34 make_default: bool,
35) -> Result<String, StoreError> {
36 let existing = match std::fs::read_to_string(path) {
37 Ok(text) => text,
38 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
39 Err(error) => return Err(StoreError::Read(error)),
40 };
41
42 let mut document: DocumentMut = existing.parse()?;
43
44 let accounts = implicit_table(&mut document, "accounts");
45 let entry = accounts
46 .entry(account)
47 .or_insert_with(|| Item::Table(Table::new()));
48 if let (Some(table), Some(description)) = (entry.as_table_mut(), description) {
49 table["description"] = value(description);
50 }
51
52 if let Some((name, profile)) = profile {
53 let profiles = implicit_table(&mut document, "profiles");
54 let entry = profiles
55 .entry(name)
56 .or_insert_with(|| Item::Table(Table::new()));
57 if let Some(table) = entry.as_table_mut() {
58 table["account"] = value(&profile.account);
59 table["org_id"] = value(&profile.org_id);
60 table["org_kind"] = value(kind_name(profile.org_kind));
61 match &profile.default_queue {
62 Some(queue) => table["default_queue"] = value(queue),
63 None => {
64 table.remove("default_queue");
65 }
66 }
67 if let Some(description) = &profile.description {
73 table["description"] = value(description);
74 }
75 }
76
77 if make_default {
81 document["default_profile"] = value(name);
82 }
83 }
84
85 let rendered = document.to_string();
86
87 if let Some(parent) = path.parent() {
88 std::fs::create_dir_all(parent).map_err(StoreError::Write)?;
89 }
90 write_private(path, &rendered).map_err(StoreError::Write)?;
91
92 Ok(rendered)
93}
94
95pub fn set_default(path: &Path, name: &str) -> Result<String, StoreError> {
102 let existing = match std::fs::read_to_string(path) {
103 Ok(text) => text,
104 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
105 Err(error) => return Err(StoreError::Read(error)),
106 };
107
108 let mut document: DocumentMut = existing.parse()?;
109 document["default_profile"] = value(name);
110 let rendered = document.to_string();
111
112 if let Some(parent) = path.parent() {
113 std::fs::create_dir_all(parent).map_err(StoreError::Write)?;
114 }
115 write_private(path, &rendered).map_err(StoreError::Write)?;
116
117 Ok(rendered)
118}
119
120#[derive(Debug, Default)]
127pub struct Edits<'a> {
128 pub name: Option<&'a str>,
130 pub account: Option<&'a str>,
131 pub org_id: Option<&'a str>,
132 pub org_kind: Option<OrgKind>,
133 pub description: Option<Option<&'a str>>,
134 pub default_queue: Option<Option<&'a str>>,
135}
136
137impl Edits<'_> {
138 #[must_use]
141 pub fn is_empty(&self) -> bool {
142 self.name.is_none()
143 && self.account.is_none()
144 && self.org_id.is_none()
145 && self.org_kind.is_none()
146 && self.description.is_none()
147 && self.default_queue.is_none()
148 }
149}
150
151#[derive(Debug, thiserror::Error)]
152pub enum EditError {
153 #[error("no profile called `{0}` in the configuration file")]
154 Unknown(String),
155 #[error("a profile called `{0}` already exists; pick another name or remove that one")]
156 NameTaken(String),
157 #[error(transparent)]
158 Store(#[from] StoreError),
159}
160
161pub fn edit(path: &Path, profile: &str, edits: &Edits<'_>) -> Result<String, EditError> {
173 let existing = match std::fs::read_to_string(path) {
174 Ok(text) => text,
175 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
176 Err(error) => return Err(StoreError::Read(error).into()),
177 };
178
179 let mut document: DocumentMut = existing.parse().map_err(StoreError::from)?;
180
181 let profiles = implicit_table(&mut document, "profiles");
182 if !profiles.contains_key(profile) {
183 return Err(EditError::Unknown(profile.to_owned()));
184 }
185 if let Some(taken) = edits
186 .name
187 .filter(|name| *name != profile && profiles.contains_key(name))
188 {
189 return Err(EditError::NameTaken(taken.to_owned()));
190 }
191
192 let entry = profiles
193 .entry(profile)
194 .or_insert_with(|| Item::Table(Table::new()));
195 if let Some(table) = entry.as_table_mut() {
196 if let Some(account) = edits.account {
197 table["account"] = value(account);
198 }
199 if let Some(org_id) = edits.org_id {
200 table["org_id"] = value(org_id);
201 }
202 if let Some(org_kind) = edits.org_kind {
203 table["org_kind"] = value(kind_name(org_kind));
204 }
205 if let Some(description) = edits.description {
206 set_or_remove(table, "description", description);
207 }
208 if let Some(queue) = edits.default_queue {
209 set_or_remove(table, "default_queue", queue);
210 }
211 }
212
213 if let Some(new_name) = edits.name.filter(|name| *name != profile) {
214 if let Some(moved) = profiles.remove(profile) {
215 profiles.insert(new_name, moved);
216 }
217 if document.get("default_profile").and_then(Item::as_str) == Some(profile) {
218 document["default_profile"] = value(new_name);
219 }
220 }
221
222 let rendered = document.to_string();
223
224 if let Some(parent) = path.parent() {
225 std::fs::create_dir_all(parent).map_err(StoreError::Write)?;
226 }
227 write_private(path, &rendered).map_err(StoreError::Write)?;
228
229 Ok(rendered)
230}
231
232fn set_or_remove(table: &mut Table, key: &str, wanted: Option<&str>) {
233 match wanted {
234 Some(text) => table[key] = value(text),
235 None => {
236 table.remove(key);
237 }
238 }
239}
240
241fn kind_name(kind: OrgKind) -> &'static str {
242 match kind {
243 OrgKind::Cloud => "cloud",
244 OrgKind::Yandex360 => "yandex360",
245 }
246}
247
248fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
254 std::fs::write(path, contents)?;
255
256 #[cfg(unix)]
257 {
258 use std::os::unix::fs::PermissionsExt;
259 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
260 }
261
262 Ok(())
263}
264
265fn implicit_table<'a>(document: &'a mut DocumentMut, name: &str) -> &'a mut Table {
268 let entry = document
269 .entry(name)
270 .or_insert_with(|| Item::Table(Table::new()));
271 if let Some(table) = entry.as_table_mut() {
272 table.set_implicit(true);
273 }
274 entry
275 .as_table_mut()
276 .unwrap_or_else(|| unreachable!("just inserted a table"))
277}
278
279#[cfg(test)]
280#[allow(clippy::expect_used, clippy::unwrap_used)]
281mod tests {
282 use super::*;
283 use crate::config::Display;
284
285 fn profile() -> Profile {
286 Profile {
287 account: "work".to_owned(),
288 org_id: "12345".to_owned(),
289 org_kind: OrgKind::Cloud,
290 description: None,
291 default_queue: Some("PROJ".to_owned()),
292 display: Display::default(),
293 }
294 }
295
296 #[test]
299 fn setting_the_default_keeps_what_was_written_around_it() {
300 let dir = tempfile::tempdir().expect("temp dir");
301 let path = dir.path().join("config.toml");
302 std::fs::write(
303 &path,
304 "# my notes\ndefault_profile = \"work\"\n\n[profiles.home]\naccount = \"me\"\n",
305 )
306 .expect("write");
307
308 let written = set_default(&path, "home").expect("set default");
309
310 assert!(written.contains("# my notes"));
311 assert!(written.contains(r#"default_profile = "home""#));
312 assert!(written.contains("[profiles.home]"));
313 }
314
315 #[test]
316 fn writes_a_file_that_did_not_exist() {
317 let dir = tempfile::tempdir().expect("temp dir");
318 let path = dir.path().join("nested").join("config.toml");
319
320 let written = upsert(
321 &path,
322 "work",
323 Some("main"),
324 Some(("work", &profile())),
325 true,
326 )
327 .expect("written");
328
329 assert!(written.contains("[accounts.work]"));
330 assert!(written.contains("[profiles.work]"));
331 assert!(written.contains(r#"org_kind = "cloud""#));
332 assert!(written.contains(r#"default_profile = "work""#));
333 assert!(path.exists());
334 }
335
336 #[test]
339 fn keeps_comments_and_unrelated_entries() {
340 let dir = tempfile::tempdir().expect("temp dir");
341 let path = dir.path().join("config.toml");
342 std::fs::write(
343 &path,
344 r#"# my notes about which org is which
345default_profile = "other"
346
347[accounts.personal]
348description = "everyday login"
349
350[profiles.other]
351account = "personal"
352org_id = "98765"
353org_kind = "yandex360"
354"#,
355 )
356 .expect("write");
357
358 let written = upsert(
359 &path,
360 "work",
361 Some("admin"),
362 Some(("work", &profile())),
363 false,
364 )
365 .expect("written");
366
367 assert!(written.contains("# my notes about which org is which"));
368 assert!(written.contains("[accounts.personal]"));
369 assert!(written.contains("[profiles.other]"));
370 assert!(written.contains("[profiles.work]"));
371 assert!(written.contains(r#"default_profile = "other""#));
373 }
374
375 #[test]
376 fn updating_an_existing_profile_replaces_its_fields() {
377 let dir = tempfile::tempdir().expect("temp dir");
378 let path = dir.path().join("config.toml");
379 upsert(&path, "work", None, Some(("work", &profile())), true).expect("first");
380
381 let mut moved = profile();
382 moved.org_id = "999".to_owned();
383 moved.org_kind = OrgKind::Yandex360;
384 moved.default_queue = None;
385 let written = upsert(&path, "work", None, Some(("work", &moved)), false).expect("second");
386
387 assert!(written.contains(r#"org_id = "999""#));
388 assert!(written.contains(r#"org_kind = "yandex360""#));
389 assert!(!written.contains("default_queue"));
390 assert_eq!(written.matches("[profiles.work]").count(), 1);
391 }
392
393 #[test]
396 fn a_login_that_says_nothing_about_the_description_keeps_the_one_on_file() {
397 let dir = tempfile::tempdir().expect("temp dir");
398 let path = dir.path().join("config.toml");
399 let mut described = profile();
400 described.description = Some("production — customer data".to_owned());
401 upsert(&path, "work", None, Some(("work", &described)), true).expect("first");
402
403 let written =
404 upsert(&path, "work", None, Some(("work", &profile())), false).expect("second");
405
406 assert!(written.contains(r#"description = "production — customer data""#));
407 }
408
409 #[test]
410 fn a_description_can_be_set_and_removed_without_touching_anything_else() {
411 let dir = tempfile::tempdir().expect("temp dir");
412 let path = dir.path().join("config.toml");
413 std::fs::write(
414 &path,
415 "# my notes\n\n[profiles.work]\naccount = \"me\"\norg_id = \"12345\"\n",
416 )
417 .expect("write");
418
419 let written = edit(
420 &path,
421 "work",
422 &Edits {
423 description: Some(Some("sandbox")),
424 ..Edits::default()
425 },
426 )
427 .expect("set");
428 assert!(written.contains(r#"description = "sandbox""#));
429 assert!(written.contains("# my notes"));
430 assert!(written.contains(r#"org_id = "12345""#));
431
432 let cleared = edit(
433 &path,
434 "work",
435 &Edits {
436 description: Some(None),
437 ..Edits::default()
438 },
439 )
440 .expect("clear");
441 assert!(!cleared.contains("description"));
442 assert!(cleared.contains(r#"org_id = "12345""#));
443 }
444
445 #[test]
448 fn renaming_moves_the_whole_profile_and_the_default_with_it() {
449 let dir = tempfile::tempdir().expect("temp dir");
450 let path = dir.path().join("config.toml");
451 std::fs::write(
452 &path,
453 r#"default_profile = "work"
454
455[profiles.work]
456account = "me"
457org_id = "12345"
458org_kind = "cloud"
459
460[profiles.work.display]
461limit = 5
462"#,
463 )
464 .expect("write");
465
466 let written = edit(
467 &path,
468 "work",
469 &Edits {
470 name: Some("prod"),
471 description: Some(Some("production")),
472 ..Edits::default()
473 },
474 )
475 .expect("renamed");
476
477 assert!(written.contains("[profiles.prod]"));
478 assert!(written.contains("[profiles.prod.display]"));
479 assert!(written.contains("limit = 5"));
480 assert!(written.contains(r#"default_profile = "prod""#));
481 assert!(written.contains(r#"description = "production""#));
482 assert!(!written.contains("[profiles.work]"));
483 }
484
485 #[test]
488 fn renaming_onto_an_existing_profile_is_refused() {
489 let dir = tempfile::tempdir().expect("temp dir");
490 let path = dir.path().join("config.toml");
491 std::fs::write(
492 &path,
493 "[profiles.work]\naccount = \"me\"\n\n[profiles.home]\naccount = \"me\"\n",
494 )
495 .expect("write");
496
497 let error = edit(
498 &path,
499 "work",
500 &Edits {
501 name: Some("home"),
502 ..Edits::default()
503 },
504 )
505 .expect_err("refused");
506
507 assert!(matches!(error, EditError::NameTaken(name) if name == "home"));
508 let still = std::fs::read_to_string(&path).expect("readable");
509 assert!(still.contains("[profiles.work]"));
510 }
511
512 #[test]
513 fn editing_a_profile_that_does_not_exist_says_so() {
514 let dir = tempfile::tempdir().expect("temp dir");
515 let path = dir.path().join("config.toml");
516 std::fs::write(&path, "[profiles.work]\naccount = \"me\"\n").expect("write");
517
518 let error = edit(
519 &path,
520 "nope",
521 &Edits {
522 org_id: Some("1"),
523 ..Edits::default()
524 },
525 )
526 .expect_err("refused");
527
528 assert!(matches!(error, EditError::Unknown(name) if name == "nope"));
529 }
530
531 #[test]
532 fn a_broken_file_is_reported_rather_than_overwritten() {
533 let dir = tempfile::tempdir().expect("temp dir");
534 let path = dir.path().join("config.toml");
535 std::fs::write(&path, "this is not [[[ toml").expect("write");
536
537 assert!(upsert(&path, "work", None, None, false).is_err());
538 assert_eq!(
539 std::fs::read_to_string(&path).expect("still there"),
540 "this is not [[[ toml"
541 );
542 }
543}