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
232fn set_or_remove(table: &mut Table, key: &str, wanted: Option<&str>) {
233    match wanted {
234        Some(text) => table[key] = value(text),
235        None => {
236            table.remove(key);
237        }
238    }
239}
240
241fn kind_name(kind: OrgKind) -> &'static str {
242    match kind {
243        OrgKind::Cloud => "cloud",
244        OrgKind::Yandex360 => "yandex360",
245    }
246}
247
248/// Write with owner-only permissions.
249///
250/// The file holds no secrets by design, but it does name organisations and
251/// accounts, and a config a group can rewrite is a config someone else can point
252/// at their own organisation.
253fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
254    std::fs::write(path, contents)?;
255
256    #[cfg(unix)]
257    {
258        use std::os::unix::fs::PermissionsExt;
259        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
260    }
261
262    Ok(())
263}
264
265/// A `[accounts.x]`-style parent table, created without emitting an empty
266/// `[accounts]` header of its own.
267fn implicit_table<'a>(document: &'a mut DocumentMut, name: &str) -> &'a mut Table {
268    let entry = document
269        .entry(name)
270        .or_insert_with(|| Item::Table(Table::new()));
271    if let Some(table) = entry.as_table_mut() {
272        table.set_implicit(true);
273    }
274    entry
275        .as_table_mut()
276        .unwrap_or_else(|| unreachable!("just inserted a table"))
277}
278
279#[cfg(test)]
280#[allow(clippy::expect_used, clippy::unwrap_used)]
281mod tests {
282    use super::*;
283    use crate::config::Display;
284
285    fn profile() -> Profile {
286        Profile {
287            account: "work".to_owned(),
288            org_id: "12345".to_owned(),
289            org_kind: OrgKind::Cloud,
290            description: None,
291            default_queue: Some("PROJ".to_owned()),
292            display: Display::default(),
293        }
294    }
295
296    /// The config is a file people write by hand, and the docs tell them to.
297    /// Switching the default must not cost them their comments.
298    #[test]
299    fn setting_the_default_keeps_what_was_written_around_it() {
300        let dir = tempfile::tempdir().expect("temp dir");
301        let path = dir.path().join("config.toml");
302        std::fs::write(
303            &path,
304            "# my notes\ndefault_profile = \"work\"\n\n[profiles.home]\naccount = \"me\"\n",
305        )
306        .expect("write");
307
308        let written = set_default(&path, "home").expect("set default");
309
310        assert!(written.contains("# my notes"));
311        assert!(written.contains(r#"default_profile = "home""#));
312        assert!(written.contains("[profiles.home]"));
313    }
314
315    #[test]
316    fn writes_a_file_that_did_not_exist() {
317        let dir = tempfile::tempdir().expect("temp dir");
318        let path = dir.path().join("nested").join("config.toml");
319
320        let written = upsert(
321            &path,
322            "work",
323            Some("main"),
324            Some(("work", &profile())),
325            true,
326        )
327        .expect("written");
328
329        assert!(written.contains("[accounts.work]"));
330        assert!(written.contains("[profiles.work]"));
331        assert!(written.contains(r#"org_kind = "cloud""#));
332        assert!(written.contains(r#"default_profile = "work""#));
333        assert!(path.exists());
334    }
335
336    /// People hand-write this file; the docs tell them to. An edit must not eat
337    /// what they wrote around it.
338    #[test]
339    fn keeps_comments_and_unrelated_entries() {
340        let dir = tempfile::tempdir().expect("temp dir");
341        let path = dir.path().join("config.toml");
342        std::fs::write(
343            &path,
344            r#"# my notes about which org is which
345default_profile = "other"
346
347[accounts.personal]
348description = "everyday login"
349
350[profiles.other]
351account = "personal"
352org_id = "98765"
353org_kind = "yandex360"
354"#,
355        )
356        .expect("write");
357
358        let written = upsert(
359            &path,
360            "work",
361            Some("admin"),
362            Some(("work", &profile())),
363            false,
364        )
365        .expect("written");
366
367        assert!(written.contains("# my notes about which org is which"));
368        assert!(written.contains("[accounts.personal]"));
369        assert!(written.contains("[profiles.other]"));
370        assert!(written.contains("[profiles.work]"));
371        // An existing default is not moved unless asked.
372        assert!(written.contains(r#"default_profile = "other""#));
373    }
374
375    #[test]
376    fn updating_an_existing_profile_replaces_its_fields() {
377        let dir = tempfile::tempdir().expect("temp dir");
378        let path = dir.path().join("config.toml");
379        upsert(&path, "work", None, Some(("work", &profile())), true).expect("first");
380
381        let mut moved = profile();
382        moved.org_id = "999".to_owned();
383        moved.org_kind = OrgKind::Yandex360;
384        moved.default_queue = None;
385        let written = upsert(&path, "work", None, Some(("work", &moved)), false).expect("second");
386
387        assert!(written.contains(r#"org_id = "999""#));
388        assert!(written.contains(r#"org_kind = "yandex360""#));
389        assert!(!written.contains("default_queue"));
390        assert_eq!(written.matches("[profiles.work]").count(), 1);
391    }
392
393    /// The note is about the organisation, which has not changed because a
394    /// token was renewed. A login that does not mention it must leave it be.
395    #[test]
396    fn a_login_that_says_nothing_about_the_description_keeps_the_one_on_file() {
397        let dir = tempfile::tempdir().expect("temp dir");
398        let path = dir.path().join("config.toml");
399        let mut described = profile();
400        described.description = Some("production — customer data".to_owned());
401        upsert(&path, "work", None, Some(("work", &described)), true).expect("first");
402
403        let written =
404            upsert(&path, "work", None, Some(("work", &profile())), false).expect("second");
405
406        assert!(written.contains(r#"description = "production — customer data""#));
407    }
408
409    #[test]
410    fn a_description_can_be_set_and_removed_without_touching_anything_else() {
411        let dir = tempfile::tempdir().expect("temp dir");
412        let path = dir.path().join("config.toml");
413        std::fs::write(
414            &path,
415            "# my notes\n\n[profiles.work]\naccount = \"me\"\norg_id = \"12345\"\n",
416        )
417        .expect("write");
418
419        let written = edit(
420            &path,
421            "work",
422            &Edits {
423                description: Some(Some("sandbox")),
424                ..Edits::default()
425            },
426        )
427        .expect("set");
428        assert!(written.contains(r#"description = "sandbox""#));
429        assert!(written.contains("# my notes"));
430        assert!(written.contains(r#"org_id = "12345""#));
431
432        let cleared = edit(
433            &path,
434            "work",
435            &Edits {
436                description: Some(None),
437                ..Edits::default()
438            },
439        )
440        .expect("clear");
441        assert!(!cleared.contains("description"));
442        assert!(cleared.contains(r#"org_id = "12345""#));
443    }
444
445    /// A rename that loses the display settings, or leaves `default_profile`
446    /// pointing at a name that no longer exists, is worse than no rename.
447    #[test]
448    fn renaming_moves_the_whole_profile_and_the_default_with_it() {
449        let dir = tempfile::tempdir().expect("temp dir");
450        let path = dir.path().join("config.toml");
451        std::fs::write(
452            &path,
453            r#"default_profile = "work"
454
455[profiles.work]
456account = "me"
457org_id = "12345"
458org_kind = "cloud"
459
460[profiles.work.display]
461limit = 5
462"#,
463        )
464        .expect("write");
465
466        let written = edit(
467            &path,
468            "work",
469            &Edits {
470                name: Some("prod"),
471                description: Some(Some("production")),
472                ..Edits::default()
473            },
474        )
475        .expect("renamed");
476
477        assert!(written.contains("[profiles.prod]"));
478        assert!(written.contains("[profiles.prod.display]"));
479        assert!(written.contains("limit = 5"));
480        assert!(written.contains(r#"default_profile = "prod""#));
481        assert!(written.contains(r#"description = "production""#));
482        assert!(!written.contains("[profiles.work]"));
483    }
484
485    /// Renaming onto a name in use would merge two organisations into one
486    /// profile, silently. It is refused instead.
487    #[test]
488    fn renaming_onto_an_existing_profile_is_refused() {
489        let dir = tempfile::tempdir().expect("temp dir");
490        let path = dir.path().join("config.toml");
491        std::fs::write(
492            &path,
493            "[profiles.work]\naccount = \"me\"\n\n[profiles.home]\naccount = \"me\"\n",
494        )
495        .expect("write");
496
497        let error = edit(
498            &path,
499            "work",
500            &Edits {
501                name: Some("home"),
502                ..Edits::default()
503            },
504        )
505        .expect_err("refused");
506
507        assert!(matches!(error, EditError::NameTaken(name) if name == "home"));
508        let still = std::fs::read_to_string(&path).expect("readable");
509        assert!(still.contains("[profiles.work]"));
510    }
511
512    #[test]
513    fn editing_a_profile_that_does_not_exist_says_so() {
514        let dir = tempfile::tempdir().expect("temp dir");
515        let path = dir.path().join("config.toml");
516        std::fs::write(&path, "[profiles.work]\naccount = \"me\"\n").expect("write");
517
518        let error = edit(
519            &path,
520            "nope",
521            &Edits {
522                org_id: Some("1"),
523                ..Edits::default()
524            },
525        )
526        .expect_err("refused");
527
528        assert!(matches!(error, EditError::Unknown(name) if name == "nope"));
529    }
530
531    #[test]
532    fn a_broken_file_is_reported_rather_than_overwritten() {
533        let dir = tempfile::tempdir().expect("temp dir");
534        let path = dir.path().join("config.toml");
535        std::fs::write(&path, "this is not [[[ toml").expect("write");
536
537        assert!(upsert(&path, "work", None, None, false).is_err());
538        assert_eq!(
539            std::fs::read_to_string(&path).expect("still there"),
540            "this is not [[[ toml"
541        );
542    }
543}