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    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            // Unlike the keys above, an absent description means "not said"
68            // rather than "cleared": login knows the intended queue every time
69            // it runs and does not ask about the note unless told to, so
70            // rewriting the profile must leave a hand-written one alone.
71            // `edit` is how it goes away.
72            if let Some(description) = &profile.description {
73                table["description"] = value(description);
74            }
75        }
76
77        // The caller decides whether to become the default; it never happens as
78        // a side effect of writing a profile, because which profile is default
79        // decides which organisation a bare command touches.
80        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
95/// Point `default_profile` at an existing profile.
96///
97/// Separate from [`upsert`] because changing which organisation a bare command
98/// touches is its own decision, not a side effect of writing a profile — and
99/// because it must not require a token: switching profiles is a local edit, and
100/// asking the keychain for a credential to make one would be theatre.
101pub 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/// What an edit changes about a profile.
121///
122/// Two levels of optionality, and they mean different things: the outer
123/// `None` is "not mentioned, leave it alone", and `Some(None)` on the fields
124/// that have it is "remove this key". A command that edits one thing must not
125/// quietly rewrite the rest.
126#[derive(Debug, Default)]
127pub struct Edits<'a> {
128    /// Rename the profile itself.
129    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    /// Nothing to do. The caller refuses rather than rewriting the file for no
139    /// reason: a no-op that reports success looks exactly like a change.
140    #[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
161/// Change an existing profile in place, optionally renaming it.
162///
163/// Its own function rather than a mode of [`upsert`], and for the same reason
164/// [`set_default`] is: editing a profile is a local edit to a file the user
165/// owns, and making them log in again — token, verification and all — to fix a
166/// typo in an organisation id would be theatre.
167///
168/// A rename moves the table rather than copying its fields, so `[profiles.x.display]`
169/// and anything hand-written inside it travel with it, and `default_profile` is
170/// carried across because a default naming a profile that no longer exists
171/// breaks every later command.
172pub 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/// What removing a profile changed beyond the profile itself.
233#[derive(Debug)]
234pub struct Removed {
235    /// `default_profile` named this profile and was dropped with it.
236    pub cleared_default: bool,
237    /// The file's new contents, so the caller need not read it back.
238    pub contents: String,
239}
240
241/// Delete a profile from the config file.
242///
243/// The whole `[profiles.x]` table goes, display settings included: half a
244/// profile is worse than none, because the keys left behind still resolve and
245/// still send requests. `default_profile` naming it is dropped rather than
246/// pointed somewhere else — guessing which organisation should inherit the
247/// bare command is exactly the guess this tool does not make.
248///
249/// The account and its keychain token are untouched: an account can back
250/// several profiles, and forgetting a credential is `auth logout`.
251pub 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        // A comment written above `default_profile` is usually about the file
268        // rather than about that one key, so it outlives the key: dropping it
269        // with the line would quietly edit prose the user wrote by hand.
270        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
297/// Whether a chunk of decor holds anything a person wrote.
298fn has_comment(prefix: &str) -> bool {
299    prefix
300        .lines()
301        .any(|line| line.trim_start().starts_with('#'))
302}
303
304/// Move a departing key's leading comment onto whatever now comes first.
305///
306/// "First" is the first thing that is actually written out, which is not always
307/// the first entry: `[accounts]` exists in the tree while only `[accounts.admin]`
308/// appears in the file, and decor on a table nobody renders is decor nobody
309/// reads.
310fn 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    // A table wears its comment above its header; a plain key wears it above
327    // the key itself.
328    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
365/// Write with owner-only permissions.
366///
367/// The file holds no secrets by design, but it does name organisations and
368/// accounts, and a config a group can rewrite is a config someone else can point
369/// at their own organisation.
370fn 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
382/// A `[accounts.x]`-style parent table, created without emitting an empty
383/// `[accounts]` header of its own.
384fn 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    /// The config is a file people write by hand, and the docs tell them to.
414    /// Switching the default must not cost them their comments.
415    #[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    /// People hand-write this file; the docs tell them to. An edit must not eat
454    /// what they wrote around it.
455    #[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        // An existing default is not moved unless asked.
489        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    /// The note is about the organisation, which has not changed because a
511    /// token was renewed. A login that does not mention it must leave it be.
512    #[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    /// A rename that loses the display settings, or leaves `default_profile`
563    /// pointing at a name that no longer exists, is worse than no rename.
564    #[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    /// Renaming onto a name in use would merge two organisations into one
603    /// profile, silently. It is refused instead.
604    #[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    /// The whole table goes, display settings included, and the profiles that
649    /// share the file are left exactly as they were.
650    #[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    /// A `default_profile` naming a profile that no longer exists fails every
670    /// later command, so it goes with it — and it is not silently pointed at
671    /// somebody else's organisation.
672    #[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}