Skip to main content

ytcli/config/
store.rs

1//! Writing the config file back.
2//!
3//! `auth login` is the only path that edits configuration, and it edits a file
4//! people also write by hand — the docs tell them to. So the edit is surgical:
5//! `toml_edit` keeps existing comments, key order and formatting, and only the
6//! touched keys change. Serialising the whole struct back would silently delete
7//! whatever the user had written around it.
8
9use 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
25/// Add or update an account and, optionally, a profile pointing at it.
26///
27/// Returns the file's new contents, so a caller can show what changed without
28/// reading the file back.
29pub 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        // A new token replaces the old one's rights, so an unknown access
54        // clears what a previous sign-in recorded rather than keeping it.
55        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            // Unlike the keys above, an absent description means "not said"
79            // rather than "cleared": login knows the intended queue every time
80            // it runs and does not ask about the note unless told to, so
81            // rewriting the profile must leave a hand-written one alone.
82            // `edit` is how it goes away.
83            if let Some(description) = &profile.description {
84                table["description"] = value(description);
85            }
86        }
87
88        // The caller decides whether to become the default; it never happens as
89        // a side effect of writing a profile, because which profile is default
90        // decides which organisation a bare command touches.
91        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
106/// Point `default_profile` at an existing profile.
107///
108/// Separate from [`upsert`] because changing which organisation a bare command
109/// touches is its own decision, not a side effect of writing a profile — and
110/// because it must not require a token: switching profiles is a local edit, and
111/// asking the keychain for a credential to make one would be theatre.
112pub 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/// What an edit changes about a profile.
132///
133/// Two levels of optionality, and they mean different things: the outer
134/// `None` is "not mentioned, leave it alone", and `Some(None)` on the fields
135/// that have it is "remove this key". A command that edits one thing must not
136/// quietly rewrite the rest.
137#[derive(Debug, Default)]
138pub struct Edits<'a> {
139    /// Rename the profile itself.
140    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    /// Nothing to do. The caller refuses rather than rewriting the file for no
150    /// reason: a no-op that reports success looks exactly like a change.
151    #[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
172/// Change an existing profile in place, optionally renaming it.
173///
174/// Its own function rather than a mode of [`upsert`], and for the same reason
175/// [`set_default`] is: editing a profile is a local edit to a file the user
176/// owns, and making them log in again — token, verification and all — to fix a
177/// typo in an organisation id would be theatre.
178///
179/// A rename moves the table rather than copying its fields, so `[profiles.x.display]`
180/// and anything hand-written inside it travel with it, and `default_profile` is
181/// carried across because a default naming a profile that no longer exists
182/// breaks every later command.
183pub 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/// What removing a profile changed beyond the profile itself.
244#[derive(Debug)]
245pub struct Removed {
246    /// `default_profile` named this profile and was dropped with it.
247    pub cleared_default: bool,
248    /// The file's new contents, so the caller need not read it back.
249    pub contents: String,
250}
251
252/// Delete a profile from the config file.
253///
254/// The whole `[profiles.x]` table goes, display settings included: half a
255/// profile is worse than none, because the keys left behind still resolve and
256/// still send requests. `default_profile` naming it is dropped rather than
257/// pointed somewhere else — guessing which organisation should inherit the
258/// bare command is exactly the guess this tool does not make.
259///
260/// The account and its keychain token are untouched: an account can back
261/// several profiles, and forgetting a credential is `auth logout`.
262pub 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        // A comment written above `default_profile` is usually about the file
279        // rather than about that one key, so it outlives the key: dropping it
280        // with the line would quietly edit prose the user wrote by hand.
281        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
308/// Whether a chunk of decor holds anything a person wrote.
309fn has_comment(prefix: &str) -> bool {
310    prefix
311        .lines()
312        .any(|line| line.trim_start().starts_with('#'))
313}
314
315/// Move a departing key's leading comment onto whatever now comes first.
316///
317/// "First" is the first thing that is actually written out, which is not always
318/// the first entry: `[accounts]` exists in the tree while only `[accounts.admin]`
319/// appears in the file, and decor on a table nobody renders is decor nobody
320/// reads.
321fn 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    // A table wears its comment above its header; a plain key wears it above
338    // the key itself.
339    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
376/// Write with owner-only permissions.
377///
378/// The file holds no secrets by design, but it does name organisations and
379/// accounts, and a config a group can rewrite is a config someone else can point
380/// at their own organisation.
381fn 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
393/// A `[accounts.x]`-style parent table, created without emitting an empty
394/// `[accounts]` header of its own.
395fn 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    /// The config is a file people write by hand, and the docs tell them to.
425    /// Switching the default must not cost them their comments.
426    #[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    /// People hand-write this file; the docs tell them to. An edit must not eat
466    /// what they wrote around it.
467    #[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        // An existing default is not moved unless asked.
502        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    /// The note is about the organisation, which has not changed because a
525    /// token was renewed. A login that does not mention it must leave it be.
526    #[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    /// A rename that loses the display settings, or leaves `default_profile`
577    /// pointing at a name that no longer exists, is worse than no rename.
578    #[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    /// Renaming onto a name in use would merge two organisations into one
617    /// profile, silently. It is refused instead.
618    #[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    /// The whole table goes, display settings included, and the profiles that
663    /// share the file are left exactly as they were.
664    #[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    /// A `default_profile` naming a profile that no longer exists fails every
684    /// later command, so it goes with it — and it is not silently pointed at
685    /// somebody else's organisation.
686    #[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    /// A sign-in records what the token may do; a pasted token after it takes
737    /// that record away rather than inheriting it.
738    #[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}