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 access: Option<crate::config::Access>,
34 profile: Option<(&str, &Profile)>,
35 make_default: bool,
36) -> Result<String, StoreError> {
37 let existing = match std::fs::read_to_string(path) {
38 Ok(text) => text,
39 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
40 Err(error) => return Err(StoreError::Read(error)),
41 };
42
43 let mut document: DocumentMut = existing.parse()?;
44
45 let accounts = implicit_table(&mut document, "accounts");
46 let entry = accounts
47 .entry(account)
48 .or_insert_with(|| Item::Table(Table::new()));
49 if let Some(table) = entry.as_table_mut() {
50 if let Some(description) = description {
51 table["description"] = value(description);
52 }
53 match access {
56 Some(access) => table["access"] = value(access.name()),
57 None => {
58 table.remove("access");
59 }
60 }
61 }
62
63 if let Some((name, profile)) = profile {
64 let profiles = implicit_table(&mut document, "profiles");
65 let entry = profiles
66 .entry(name)
67 .or_insert_with(|| Item::Table(Table::new()));
68 if let Some(table) = entry.as_table_mut() {
69 table["account"] = value(&profile.account);
70 table["org_id"] = value(&profile.org_id);
71 table["org_kind"] = value(kind_name(profile.org_kind));
72 match &profile.default_queue {
73 Some(queue) => table["default_queue"] = value(queue),
74 None => {
75 table.remove("default_queue");
76 }
77 }
78 if let Some(description) = &profile.description {
84 table["description"] = value(description);
85 }
86 }
87
88 if make_default {
92 document["default_profile"] = value(name);
93 }
94 }
95
96 let rendered = document.to_string();
97
98 if let Some(parent) = path.parent() {
99 std::fs::create_dir_all(parent).map_err(StoreError::Write)?;
100 }
101 write_private(path, &rendered).map_err(StoreError::Write)?;
102
103 Ok(rendered)
104}
105
106pub fn set_default(path: &Path, name: &str) -> Result<String, StoreError> {
113 let existing = match std::fs::read_to_string(path) {
114 Ok(text) => text,
115 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
116 Err(error) => return Err(StoreError::Read(error)),
117 };
118
119 let mut document: DocumentMut = existing.parse()?;
120 document["default_profile"] = value(name);
121 let rendered = document.to_string();
122
123 if let Some(parent) = path.parent() {
124 std::fs::create_dir_all(parent).map_err(StoreError::Write)?;
125 }
126 write_private(path, &rendered).map_err(StoreError::Write)?;
127
128 Ok(rendered)
129}
130
131#[derive(Debug, Default)]
138pub struct Edits<'a> {
139 pub name: Option<&'a str>,
141 pub account: Option<&'a str>,
142 pub org_id: Option<&'a str>,
143 pub org_kind: Option<OrgKind>,
144 pub description: Option<Option<&'a str>>,
145 pub default_queue: Option<Option<&'a str>>,
146}
147
148impl Edits<'_> {
149 #[must_use]
152 pub fn is_empty(&self) -> bool {
153 self.name.is_none()
154 && self.account.is_none()
155 && self.org_id.is_none()
156 && self.org_kind.is_none()
157 && self.description.is_none()
158 && self.default_queue.is_none()
159 }
160}
161
162#[derive(Debug, thiserror::Error)]
163pub enum EditError {
164 #[error("no profile called `{0}` in the configuration file")]
165 Unknown(String),
166 #[error("a profile called `{0}` already exists; pick another name or remove that one")]
167 NameTaken(String),
168 #[error(transparent)]
169 Store(#[from] StoreError),
170}
171
172pub fn edit(path: &Path, profile: &str, edits: &Edits<'_>) -> Result<String, EditError> {
184 let existing = match std::fs::read_to_string(path) {
185 Ok(text) => text,
186 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
187 Err(error) => return Err(StoreError::Read(error).into()),
188 };
189
190 let mut document: DocumentMut = existing.parse().map_err(StoreError::from)?;
191
192 let profiles = implicit_table(&mut document, "profiles");
193 if !profiles.contains_key(profile) {
194 return Err(EditError::Unknown(profile.to_owned()));
195 }
196 if let Some(taken) = edits
197 .name
198 .filter(|name| *name != profile && profiles.contains_key(name))
199 {
200 return Err(EditError::NameTaken(taken.to_owned()));
201 }
202
203 let entry = profiles
204 .entry(profile)
205 .or_insert_with(|| Item::Table(Table::new()));
206 if let Some(table) = entry.as_table_mut() {
207 if let Some(account) = edits.account {
208 table["account"] = value(account);
209 }
210 if let Some(org_id) = edits.org_id {
211 table["org_id"] = value(org_id);
212 }
213 if let Some(org_kind) = edits.org_kind {
214 table["org_kind"] = value(kind_name(org_kind));
215 }
216 if let Some(description) = edits.description {
217 set_or_remove(table, "description", description);
218 }
219 if let Some(queue) = edits.default_queue {
220 set_or_remove(table, "default_queue", queue);
221 }
222 }
223
224 if let Some(new_name) = edits.name.filter(|name| *name != profile) {
225 if let Some(moved) = profiles.remove(profile) {
226 profiles.insert(new_name, moved);
227 }
228 if document.get("default_profile").and_then(Item::as_str) == Some(profile) {
229 document["default_profile"] = value(new_name);
230 }
231 }
232
233 let rendered = document.to_string();
234
235 if let Some(parent) = path.parent() {
236 std::fs::create_dir_all(parent).map_err(StoreError::Write)?;
237 }
238 write_private(path, &rendered).map_err(StoreError::Write)?;
239
240 Ok(rendered)
241}
242
243#[derive(Debug)]
245pub struct Removed {
246 pub cleared_default: bool,
248 pub contents: String,
250}
251
252pub fn remove(path: &Path, profile: &str) -> Result<Removed, EditError> {
263 let existing = match std::fs::read_to_string(path) {
264 Ok(text) => text,
265 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
266 Err(error) => return Err(StoreError::Read(error).into()),
267 };
268
269 let mut document: DocumentMut = existing.parse().map_err(StoreError::from)?;
270
271 let profiles = implicit_table(&mut document, "profiles");
272 if profiles.remove(profile).is_none() {
273 return Err(EditError::Unknown(profile.to_owned()));
274 }
275
276 let cleared_default = document.get("default_profile").and_then(Item::as_str) == Some(profile);
277 if cleared_default {
278 let carried = document
282 .as_table()
283 .key("default_profile")
284 .and_then(|key| key.leaf_decor().prefix().cloned());
285 document.remove("default_profile");
286 if let Some(text) = carried
287 .as_ref()
288 .and_then(toml_edit::RawString::as_str)
289 .filter(|prefix| has_comment(prefix))
290 {
291 carry_prefix(document.as_table_mut(), text);
292 }
293 }
294
295 let rendered = document.to_string();
296
297 if let Some(parent) = path.parent() {
298 std::fs::create_dir_all(parent).map_err(StoreError::Write)?;
299 }
300 write_private(path, &rendered).map_err(StoreError::Write)?;
301
302 Ok(Removed {
303 cleared_default,
304 contents: rendered,
305 })
306}
307
308fn has_comment(prefix: &str) -> bool {
310 prefix
311 .lines()
312 .any(|line| line.trim_start().starts_with('#'))
313}
314
315fn carry_prefix(table: &mut Table, text: &str) -> bool {
322 let Some(name) = table.iter().map(|(name, _)| name.to_owned()).next() else {
323 return false;
324 };
325
326 if table
327 .get(&name)
328 .and_then(Item::as_table)
329 .is_some_and(Table::is_implicit)
330 {
331 return table
332 .get_mut(&name)
333 .and_then(Item::as_table_mut)
334 .is_some_and(|inner| carry_prefix(inner, text));
335 }
336
337 if let Some(inner) = table.get_mut(&name).and_then(Item::as_table_mut) {
340 prepend(inner.decor_mut(), text);
341 return true;
342 }
343 if let Some(mut key) = table.key_mut(&name) {
344 prepend(key.leaf_decor_mut(), text);
345 return true;
346 }
347
348 false
349}
350
351fn prepend(decor: &mut toml_edit::Decor, text: &str) {
352 let existing = decor
353 .prefix()
354 .and_then(toml_edit::RawString::as_str)
355 .unwrap_or("")
356 .to_owned();
357 decor.set_prefix(format!("{text}{existing}"));
358}
359
360fn set_or_remove(table: &mut Table, key: &str, wanted: Option<&str>) {
361 match wanted {
362 Some(text) => table[key] = value(text),
363 None => {
364 table.remove(key);
365 }
366 }
367}
368
369fn kind_name(kind: OrgKind) -> &'static str {
370 match kind {
371 OrgKind::Cloud => "cloud",
372 OrgKind::Yandex360 => "yandex360",
373 }
374}
375
376fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
382 std::fs::write(path, contents)?;
383
384 #[cfg(unix)]
385 {
386 use std::os::unix::fs::PermissionsExt;
387 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
388 }
389
390 Ok(())
391}
392
393fn implicit_table<'a>(document: &'a mut DocumentMut, name: &str) -> &'a mut Table {
396 let entry = document
397 .entry(name)
398 .or_insert_with(|| Item::Table(Table::new()));
399 if let Some(table) = entry.as_table_mut() {
400 table.set_implicit(true);
401 }
402 entry
403 .as_table_mut()
404 .unwrap_or_else(|| unreachable!("just inserted a table"))
405}
406
407#[cfg(test)]
408#[allow(clippy::expect_used, clippy::unwrap_used)]
409mod tests {
410 use super::*;
411 use crate::config::Display;
412
413 fn profile() -> Profile {
414 Profile {
415 account: "work".to_owned(),
416 org_id: "12345".to_owned(),
417 org_kind: OrgKind::Cloud,
418 description: None,
419 default_queue: Some("PROJ".to_owned()),
420 display: Display::default(),
421 }
422 }
423
424 #[test]
427 fn setting_the_default_keeps_what_was_written_around_it() {
428 let dir = tempfile::tempdir().expect("temp dir");
429 let path = dir.path().join("config.toml");
430 std::fs::write(
431 &path,
432 "# my notes\ndefault_profile = \"work\"\n\n[profiles.home]\naccount = \"me\"\n",
433 )
434 .expect("write");
435
436 let written = set_default(&path, "home").expect("set default");
437
438 assert!(written.contains("# my notes"));
439 assert!(written.contains(r#"default_profile = "home""#));
440 assert!(written.contains("[profiles.home]"));
441 }
442
443 #[test]
444 fn writes_a_file_that_did_not_exist() {
445 let dir = tempfile::tempdir().expect("temp dir");
446 let path = dir.path().join("nested").join("config.toml");
447
448 let written = upsert(
449 &path,
450 "work",
451 Some("main"),
452 None,
453 Some(("work", &profile())),
454 true,
455 )
456 .expect("written");
457
458 assert!(written.contains("[accounts.work]"));
459 assert!(written.contains("[profiles.work]"));
460 assert!(written.contains(r#"org_kind = "cloud""#));
461 assert!(written.contains(r#"default_profile = "work""#));
462 assert!(path.exists());
463 }
464
465 #[test]
468 fn keeps_comments_and_unrelated_entries() {
469 let dir = tempfile::tempdir().expect("temp dir");
470 let path = dir.path().join("config.toml");
471 std::fs::write(
472 &path,
473 r#"# my notes about which org is which
474default_profile = "other"
475
476[accounts.personal]
477description = "everyday login"
478
479[profiles.other]
480account = "personal"
481org_id = "98765"
482org_kind = "yandex360"
483"#,
484 )
485 .expect("write");
486
487 let written = upsert(
488 &path,
489 "work",
490 Some("admin"),
491 None,
492 Some(("work", &profile())),
493 false,
494 )
495 .expect("written");
496
497 assert!(written.contains("# my notes about which org is which"));
498 assert!(written.contains("[accounts.personal]"));
499 assert!(written.contains("[profiles.other]"));
500 assert!(written.contains("[profiles.work]"));
501 assert!(written.contains(r#"default_profile = "other""#));
503 }
504
505 #[test]
506 fn updating_an_existing_profile_replaces_its_fields() {
507 let dir = tempfile::tempdir().expect("temp dir");
508 let path = dir.path().join("config.toml");
509 upsert(&path, "work", None, None, Some(("work", &profile())), true).expect("first");
510
511 let mut moved = profile();
512 moved.org_id = "999".to_owned();
513 moved.org_kind = OrgKind::Yandex360;
514 moved.default_queue = None;
515 let written =
516 upsert(&path, "work", None, None, Some(("work", &moved)), false).expect("second");
517
518 assert!(written.contains(r#"org_id = "999""#));
519 assert!(written.contains(r#"org_kind = "yandex360""#));
520 assert!(!written.contains("default_queue"));
521 assert_eq!(written.matches("[profiles.work]").count(), 1);
522 }
523
524 #[test]
527 fn a_login_that_says_nothing_about_the_description_keeps_the_one_on_file() {
528 let dir = tempfile::tempdir().expect("temp dir");
529 let path = dir.path().join("config.toml");
530 let mut described = profile();
531 described.description = Some("production — customer data".to_owned());
532 upsert(&path, "work", None, None, Some(("work", &described)), true).expect("first");
533
534 let written =
535 upsert(&path, "work", None, None, Some(("work", &profile())), false).expect("second");
536
537 assert!(written.contains(r#"description = "production — customer data""#));
538 }
539
540 #[test]
541 fn a_description_can_be_set_and_removed_without_touching_anything_else() {
542 let dir = tempfile::tempdir().expect("temp dir");
543 let path = dir.path().join("config.toml");
544 std::fs::write(
545 &path,
546 "# my notes\n\n[profiles.work]\naccount = \"me\"\norg_id = \"12345\"\n",
547 )
548 .expect("write");
549
550 let written = edit(
551 &path,
552 "work",
553 &Edits {
554 description: Some(Some("sandbox")),
555 ..Edits::default()
556 },
557 )
558 .expect("set");
559 assert!(written.contains(r#"description = "sandbox""#));
560 assert!(written.contains("# my notes"));
561 assert!(written.contains(r#"org_id = "12345""#));
562
563 let cleared = edit(
564 &path,
565 "work",
566 &Edits {
567 description: Some(None),
568 ..Edits::default()
569 },
570 )
571 .expect("clear");
572 assert!(!cleared.contains("description"));
573 assert!(cleared.contains(r#"org_id = "12345""#));
574 }
575
576 #[test]
579 fn renaming_moves_the_whole_profile_and_the_default_with_it() {
580 let dir = tempfile::tempdir().expect("temp dir");
581 let path = dir.path().join("config.toml");
582 std::fs::write(
583 &path,
584 r#"default_profile = "work"
585
586[profiles.work]
587account = "me"
588org_id = "12345"
589org_kind = "cloud"
590
591[profiles.work.display]
592limit = 5
593"#,
594 )
595 .expect("write");
596
597 let written = edit(
598 &path,
599 "work",
600 &Edits {
601 name: Some("prod"),
602 description: Some(Some("production")),
603 ..Edits::default()
604 },
605 )
606 .expect("renamed");
607
608 assert!(written.contains("[profiles.prod]"));
609 assert!(written.contains("[profiles.prod.display]"));
610 assert!(written.contains("limit = 5"));
611 assert!(written.contains(r#"default_profile = "prod""#));
612 assert!(written.contains(r#"description = "production""#));
613 assert!(!written.contains("[profiles.work]"));
614 }
615
616 #[test]
619 fn renaming_onto_an_existing_profile_is_refused() {
620 let dir = tempfile::tempdir().expect("temp dir");
621 let path = dir.path().join("config.toml");
622 std::fs::write(
623 &path,
624 "[profiles.work]\naccount = \"me\"\n\n[profiles.home]\naccount = \"me\"\n",
625 )
626 .expect("write");
627
628 let error = edit(
629 &path,
630 "work",
631 &Edits {
632 name: Some("home"),
633 ..Edits::default()
634 },
635 )
636 .expect_err("refused");
637
638 assert!(matches!(error, EditError::NameTaken(name) if name == "home"));
639 let still = std::fs::read_to_string(&path).expect("readable");
640 assert!(still.contains("[profiles.work]"));
641 }
642
643 #[test]
644 fn editing_a_profile_that_does_not_exist_says_so() {
645 let dir = tempfile::tempdir().expect("temp dir");
646 let path = dir.path().join("config.toml");
647 std::fs::write(&path, "[profiles.work]\naccount = \"me\"\n").expect("write");
648
649 let error = edit(
650 &path,
651 "nope",
652 &Edits {
653 org_id: Some("1"),
654 ..Edits::default()
655 },
656 )
657 .expect_err("refused");
658
659 assert!(matches!(error, EditError::Unknown(name) if name == "nope"));
660 }
661
662 #[test]
665 fn removing_a_profile_takes_its_display_settings_and_nothing_else() {
666 let dir = tempfile::tempdir().expect("temp dir");
667 let path = dir.path().join("config.toml");
668 std::fs::write(
669 &path,
670 "[accounts.me]\n\n[profiles.work]\naccount = \"me\"\n\n[profiles.work.display]\nwidth = 100\n\n[profiles.home]\naccount = \"me\"\n",
671 )
672 .expect("write");
673
674 let removed = remove(&path, "work").expect("removed");
675
676 assert!(!removed.cleared_default);
677 assert!(!removed.contents.contains("[profiles.work"));
678 assert!(!removed.contents.contains("width = 100"));
679 assert!(removed.contents.contains("[profiles.home]"));
680 assert!(removed.contents.contains("[accounts.me]"));
681 }
682
683 #[test]
687 fn removing_the_default_profile_drops_the_default_rather_than_moving_it() {
688 let dir = tempfile::tempdir().expect("temp dir");
689 let path = dir.path().join("config.toml");
690 std::fs::write(
691 &path,
692 "# mine\ndefault_profile = \"work\"\n\n[profiles.work]\naccount = \"me\"\n\n[profiles.home]\naccount = \"me\"\n",
693 )
694 .expect("write");
695
696 let removed = remove(&path, "work").expect("removed");
697
698 assert!(removed.cleared_default);
699 assert!(!removed.contents.contains("default_profile"));
700 assert!(
701 removed.contents.starts_with("# mine"),
702 "a comment written above the key outlives it: {}",
703 removed.contents
704 );
705 }
706
707 #[test]
708 fn removing_a_profile_that_does_not_exist_says_so() {
709 let dir = tempfile::tempdir().expect("temp dir");
710 let path = dir.path().join("config.toml");
711 std::fs::write(&path, "[profiles.work]\naccount = \"me\"\n").expect("write");
712
713 let error = remove(&path, "nope").expect_err("refused");
714
715 assert!(matches!(error, EditError::Unknown(name) if name == "nope"));
716 assert!(
717 std::fs::read_to_string(&path)
718 .expect("readable")
719 .contains("[profiles.work]")
720 );
721 }
722
723 #[test]
724 fn a_broken_file_is_reported_rather_than_overwritten() {
725 let dir = tempfile::tempdir().expect("temp dir");
726 let path = dir.path().join("config.toml");
727 std::fs::write(&path, "this is not [[[ toml").expect("write");
728
729 assert!(upsert(&path, "work", None, None, None, false).is_err());
730 assert_eq!(
731 std::fs::read_to_string(&path).expect("still there"),
732 "this is not [[[ toml"
733 );
734 }
735
736 #[test]
739 fn access_is_recorded_and_cleared_by_the_next_login() {
740 let dir = tempfile::tempdir().expect("temp dir");
741 let path = dir.path().join("config.toml");
742
743 let written = upsert(
744 &path,
745 "work",
746 None,
747 Some(crate::config::Access::Read),
748 Some(("work", &profile())),
749 true,
750 )
751 .expect("first");
752 assert!(written.contains(r#"access = "read""#), "{written}");
753
754 let written =
755 upsert(&path, "work", None, None, Some(("work", &profile())), false).expect("second");
756 assert!(!written.contains("access"), "{written}");
757 }
758}