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
232#[derive(Debug)]
234pub struct Removed {
235 pub cleared_default: bool,
237 pub contents: String,
239}
240
241pub fn remove(path: &Path, profile: &str) -> Result<Removed, EditError> {
252 let existing = match std::fs::read_to_string(path) {
253 Ok(text) => text,
254 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
255 Err(error) => return Err(StoreError::Read(error).into()),
256 };
257
258 let mut document: DocumentMut = existing.parse().map_err(StoreError::from)?;
259
260 let profiles = implicit_table(&mut document, "profiles");
261 if profiles.remove(profile).is_none() {
262 return Err(EditError::Unknown(profile.to_owned()));
263 }
264
265 let cleared_default = document.get("default_profile").and_then(Item::as_str) == Some(profile);
266 if cleared_default {
267 let carried = document
271 .as_table()
272 .key("default_profile")
273 .and_then(|key| key.leaf_decor().prefix().cloned());
274 document.remove("default_profile");
275 if let Some(text) = carried
276 .as_ref()
277 .and_then(toml_edit::RawString::as_str)
278 .filter(|prefix| has_comment(prefix))
279 {
280 carry_prefix(document.as_table_mut(), text);
281 }
282 }
283
284 let rendered = document.to_string();
285
286 if let Some(parent) = path.parent() {
287 std::fs::create_dir_all(parent).map_err(StoreError::Write)?;
288 }
289 write_private(path, &rendered).map_err(StoreError::Write)?;
290
291 Ok(Removed {
292 cleared_default,
293 contents: rendered,
294 })
295}
296
297fn has_comment(prefix: &str) -> bool {
299 prefix
300 .lines()
301 .any(|line| line.trim_start().starts_with('#'))
302}
303
304fn carry_prefix(table: &mut Table, text: &str) -> bool {
311 let Some(name) = table.iter().map(|(name, _)| name.to_owned()).next() else {
312 return false;
313 };
314
315 if table
316 .get(&name)
317 .and_then(Item::as_table)
318 .is_some_and(Table::is_implicit)
319 {
320 return table
321 .get_mut(&name)
322 .and_then(Item::as_table_mut)
323 .is_some_and(|inner| carry_prefix(inner, text));
324 }
325
326 if let Some(inner) = table.get_mut(&name).and_then(Item::as_table_mut) {
329 prepend(inner.decor_mut(), text);
330 return true;
331 }
332 if let Some(mut key) = table.key_mut(&name) {
333 prepend(key.leaf_decor_mut(), text);
334 return true;
335 }
336
337 false
338}
339
340fn prepend(decor: &mut toml_edit::Decor, text: &str) {
341 let existing = decor
342 .prefix()
343 .and_then(toml_edit::RawString::as_str)
344 .unwrap_or("")
345 .to_owned();
346 decor.set_prefix(format!("{text}{existing}"));
347}
348
349fn set_or_remove(table: &mut Table, key: &str, wanted: Option<&str>) {
350 match wanted {
351 Some(text) => table[key] = value(text),
352 None => {
353 table.remove(key);
354 }
355 }
356}
357
358fn kind_name(kind: OrgKind) -> &'static str {
359 match kind {
360 OrgKind::Cloud => "cloud",
361 OrgKind::Yandex360 => "yandex360",
362 }
363}
364
365fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
371 std::fs::write(path, contents)?;
372
373 #[cfg(unix)]
374 {
375 use std::os::unix::fs::PermissionsExt;
376 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
377 }
378
379 Ok(())
380}
381
382fn implicit_table<'a>(document: &'a mut DocumentMut, name: &str) -> &'a mut Table {
385 let entry = document
386 .entry(name)
387 .or_insert_with(|| Item::Table(Table::new()));
388 if let Some(table) = entry.as_table_mut() {
389 table.set_implicit(true);
390 }
391 entry
392 .as_table_mut()
393 .unwrap_or_else(|| unreachable!("just inserted a table"))
394}
395
396#[cfg(test)]
397#[allow(clippy::expect_used, clippy::unwrap_used)]
398mod tests {
399 use super::*;
400 use crate::config::Display;
401
402 fn profile() -> Profile {
403 Profile {
404 account: "work".to_owned(),
405 org_id: "12345".to_owned(),
406 org_kind: OrgKind::Cloud,
407 description: None,
408 default_queue: Some("PROJ".to_owned()),
409 display: Display::default(),
410 }
411 }
412
413 #[test]
416 fn setting_the_default_keeps_what_was_written_around_it() {
417 let dir = tempfile::tempdir().expect("temp dir");
418 let path = dir.path().join("config.toml");
419 std::fs::write(
420 &path,
421 "# my notes\ndefault_profile = \"work\"\n\n[profiles.home]\naccount = \"me\"\n",
422 )
423 .expect("write");
424
425 let written = set_default(&path, "home").expect("set default");
426
427 assert!(written.contains("# my notes"));
428 assert!(written.contains(r#"default_profile = "home""#));
429 assert!(written.contains("[profiles.home]"));
430 }
431
432 #[test]
433 fn writes_a_file_that_did_not_exist() {
434 let dir = tempfile::tempdir().expect("temp dir");
435 let path = dir.path().join("nested").join("config.toml");
436
437 let written = upsert(
438 &path,
439 "work",
440 Some("main"),
441 Some(("work", &profile())),
442 true,
443 )
444 .expect("written");
445
446 assert!(written.contains("[accounts.work]"));
447 assert!(written.contains("[profiles.work]"));
448 assert!(written.contains(r#"org_kind = "cloud""#));
449 assert!(written.contains(r#"default_profile = "work""#));
450 assert!(path.exists());
451 }
452
453 #[test]
456 fn keeps_comments_and_unrelated_entries() {
457 let dir = tempfile::tempdir().expect("temp dir");
458 let path = dir.path().join("config.toml");
459 std::fs::write(
460 &path,
461 r#"# my notes about which org is which
462default_profile = "other"
463
464[accounts.personal]
465description = "everyday login"
466
467[profiles.other]
468account = "personal"
469org_id = "98765"
470org_kind = "yandex360"
471"#,
472 )
473 .expect("write");
474
475 let written = upsert(
476 &path,
477 "work",
478 Some("admin"),
479 Some(("work", &profile())),
480 false,
481 )
482 .expect("written");
483
484 assert!(written.contains("# my notes about which org is which"));
485 assert!(written.contains("[accounts.personal]"));
486 assert!(written.contains("[profiles.other]"));
487 assert!(written.contains("[profiles.work]"));
488 assert!(written.contains(r#"default_profile = "other""#));
490 }
491
492 #[test]
493 fn updating_an_existing_profile_replaces_its_fields() {
494 let dir = tempfile::tempdir().expect("temp dir");
495 let path = dir.path().join("config.toml");
496 upsert(&path, "work", None, Some(("work", &profile())), true).expect("first");
497
498 let mut moved = profile();
499 moved.org_id = "999".to_owned();
500 moved.org_kind = OrgKind::Yandex360;
501 moved.default_queue = None;
502 let written = upsert(&path, "work", None, Some(("work", &moved)), false).expect("second");
503
504 assert!(written.contains(r#"org_id = "999""#));
505 assert!(written.contains(r#"org_kind = "yandex360""#));
506 assert!(!written.contains("default_queue"));
507 assert_eq!(written.matches("[profiles.work]").count(), 1);
508 }
509
510 #[test]
513 fn a_login_that_says_nothing_about_the_description_keeps_the_one_on_file() {
514 let dir = tempfile::tempdir().expect("temp dir");
515 let path = dir.path().join("config.toml");
516 let mut described = profile();
517 described.description = Some("production — customer data".to_owned());
518 upsert(&path, "work", None, Some(("work", &described)), true).expect("first");
519
520 let written =
521 upsert(&path, "work", None, Some(("work", &profile())), false).expect("second");
522
523 assert!(written.contains(r#"description = "production — customer data""#));
524 }
525
526 #[test]
527 fn a_description_can_be_set_and_removed_without_touching_anything_else() {
528 let dir = tempfile::tempdir().expect("temp dir");
529 let path = dir.path().join("config.toml");
530 std::fs::write(
531 &path,
532 "# my notes\n\n[profiles.work]\naccount = \"me\"\norg_id = \"12345\"\n",
533 )
534 .expect("write");
535
536 let written = edit(
537 &path,
538 "work",
539 &Edits {
540 description: Some(Some("sandbox")),
541 ..Edits::default()
542 },
543 )
544 .expect("set");
545 assert!(written.contains(r#"description = "sandbox""#));
546 assert!(written.contains("# my notes"));
547 assert!(written.contains(r#"org_id = "12345""#));
548
549 let cleared = edit(
550 &path,
551 "work",
552 &Edits {
553 description: Some(None),
554 ..Edits::default()
555 },
556 )
557 .expect("clear");
558 assert!(!cleared.contains("description"));
559 assert!(cleared.contains(r#"org_id = "12345""#));
560 }
561
562 #[test]
565 fn renaming_moves_the_whole_profile_and_the_default_with_it() {
566 let dir = tempfile::tempdir().expect("temp dir");
567 let path = dir.path().join("config.toml");
568 std::fs::write(
569 &path,
570 r#"default_profile = "work"
571
572[profiles.work]
573account = "me"
574org_id = "12345"
575org_kind = "cloud"
576
577[profiles.work.display]
578limit = 5
579"#,
580 )
581 .expect("write");
582
583 let written = edit(
584 &path,
585 "work",
586 &Edits {
587 name: Some("prod"),
588 description: Some(Some("production")),
589 ..Edits::default()
590 },
591 )
592 .expect("renamed");
593
594 assert!(written.contains("[profiles.prod]"));
595 assert!(written.contains("[profiles.prod.display]"));
596 assert!(written.contains("limit = 5"));
597 assert!(written.contains(r#"default_profile = "prod""#));
598 assert!(written.contains(r#"description = "production""#));
599 assert!(!written.contains("[profiles.work]"));
600 }
601
602 #[test]
605 fn renaming_onto_an_existing_profile_is_refused() {
606 let dir = tempfile::tempdir().expect("temp dir");
607 let path = dir.path().join("config.toml");
608 std::fs::write(
609 &path,
610 "[profiles.work]\naccount = \"me\"\n\n[profiles.home]\naccount = \"me\"\n",
611 )
612 .expect("write");
613
614 let error = edit(
615 &path,
616 "work",
617 &Edits {
618 name: Some("home"),
619 ..Edits::default()
620 },
621 )
622 .expect_err("refused");
623
624 assert!(matches!(error, EditError::NameTaken(name) if name == "home"));
625 let still = std::fs::read_to_string(&path).expect("readable");
626 assert!(still.contains("[profiles.work]"));
627 }
628
629 #[test]
630 fn editing_a_profile_that_does_not_exist_says_so() {
631 let dir = tempfile::tempdir().expect("temp dir");
632 let path = dir.path().join("config.toml");
633 std::fs::write(&path, "[profiles.work]\naccount = \"me\"\n").expect("write");
634
635 let error = edit(
636 &path,
637 "nope",
638 &Edits {
639 org_id: Some("1"),
640 ..Edits::default()
641 },
642 )
643 .expect_err("refused");
644
645 assert!(matches!(error, EditError::Unknown(name) if name == "nope"));
646 }
647
648 #[test]
651 fn removing_a_profile_takes_its_display_settings_and_nothing_else() {
652 let dir = tempfile::tempdir().expect("temp dir");
653 let path = dir.path().join("config.toml");
654 std::fs::write(
655 &path,
656 "[accounts.me]\n\n[profiles.work]\naccount = \"me\"\n\n[profiles.work.display]\nwidth = 100\n\n[profiles.home]\naccount = \"me\"\n",
657 )
658 .expect("write");
659
660 let removed = remove(&path, "work").expect("removed");
661
662 assert!(!removed.cleared_default);
663 assert!(!removed.contents.contains("[profiles.work"));
664 assert!(!removed.contents.contains("width = 100"));
665 assert!(removed.contents.contains("[profiles.home]"));
666 assert!(removed.contents.contains("[accounts.me]"));
667 }
668
669 #[test]
673 fn removing_the_default_profile_drops_the_default_rather_than_moving_it() {
674 let dir = tempfile::tempdir().expect("temp dir");
675 let path = dir.path().join("config.toml");
676 std::fs::write(
677 &path,
678 "# mine\ndefault_profile = \"work\"\n\n[profiles.work]\naccount = \"me\"\n\n[profiles.home]\naccount = \"me\"\n",
679 )
680 .expect("write");
681
682 let removed = remove(&path, "work").expect("removed");
683
684 assert!(removed.cleared_default);
685 assert!(!removed.contents.contains("default_profile"));
686 assert!(
687 removed.contents.starts_with("# mine"),
688 "a comment written above the key outlives it: {}",
689 removed.contents
690 );
691 }
692
693 #[test]
694 fn removing_a_profile_that_does_not_exist_says_so() {
695 let dir = tempfile::tempdir().expect("temp dir");
696 let path = dir.path().join("config.toml");
697 std::fs::write(&path, "[profiles.work]\naccount = \"me\"\n").expect("write");
698
699 let error = remove(&path, "nope").expect_err("refused");
700
701 assert!(matches!(error, EditError::Unknown(name) if name == "nope"));
702 assert!(
703 std::fs::read_to_string(&path)
704 .expect("readable")
705 .contains("[profiles.work]")
706 );
707 }
708
709 #[test]
710 fn a_broken_file_is_reported_rather_than_overwritten() {
711 let dir = tempfile::tempdir().expect("temp dir");
712 let path = dir.path().join("config.toml");
713 std::fs::write(&path, "this is not [[[ toml").expect("write");
714
715 assert!(upsert(&path, "work", None, None, false).is_err());
716 assert_eq!(
717 std::fs::read_to_string(&path).expect("still there"),
718 "this is not [[[ toml"
719 );
720 }
721}