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(match profile.org_kind {
61                OrgKind::Cloud => "cloud",
62                OrgKind::Yandex360 => "yandex360",
63            });
64            match &profile.default_queue {
65                Some(queue) => table["default_queue"] = value(queue),
66                None => {
67                    table.remove("default_queue");
68                }
69            }
70        }
71
72        // The caller decides whether to become the default; it never happens as
73        // a side effect of writing a profile, because which profile is default
74        // decides which organisation a bare command touches.
75        if make_default {
76            document["default_profile"] = value(name);
77        }
78    }
79
80    let rendered = document.to_string();
81
82    if let Some(parent) = path.parent() {
83        std::fs::create_dir_all(parent).map_err(StoreError::Write)?;
84    }
85    write_private(path, &rendered).map_err(StoreError::Write)?;
86
87    Ok(rendered)
88}
89
90/// Point `default_profile` at an existing profile.
91///
92/// Separate from [`upsert`] because changing which organisation a bare command
93/// touches is its own decision, not a side effect of writing a profile — and
94/// because it must not require a token: switching profiles is a local edit, and
95/// asking the keychain for a credential to make one would be theatre.
96pub fn set_default(path: &Path, name: &str) -> Result<String, StoreError> {
97    let existing = match std::fs::read_to_string(path) {
98        Ok(text) => text,
99        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
100        Err(error) => return Err(StoreError::Read(error)),
101    };
102
103    let mut document: DocumentMut = existing.parse()?;
104    document["default_profile"] = value(name);
105    let rendered = document.to_string();
106
107    if let Some(parent) = path.parent() {
108        std::fs::create_dir_all(parent).map_err(StoreError::Write)?;
109    }
110    write_private(path, &rendered).map_err(StoreError::Write)?;
111
112    Ok(rendered)
113}
114
115/// Write with owner-only permissions.
116///
117/// The file holds no secrets by design, but it does name organisations and
118/// accounts, and a config a group can rewrite is a config someone else can point
119/// at their own organisation.
120fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
121    std::fs::write(path, contents)?;
122
123    #[cfg(unix)]
124    {
125        use std::os::unix::fs::PermissionsExt;
126        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
127    }
128
129    Ok(())
130}
131
132/// A `[accounts.x]`-style parent table, created without emitting an empty
133/// `[accounts]` header of its own.
134fn implicit_table<'a>(document: &'a mut DocumentMut, name: &str) -> &'a mut Table {
135    let entry = document
136        .entry(name)
137        .or_insert_with(|| Item::Table(Table::new()));
138    if let Some(table) = entry.as_table_mut() {
139        table.set_implicit(true);
140    }
141    entry
142        .as_table_mut()
143        .unwrap_or_else(|| unreachable!("just inserted a table"))
144}
145
146#[cfg(test)]
147#[allow(clippy::expect_used, clippy::unwrap_used)]
148mod tests {
149    use super::*;
150    use crate::config::Display;
151
152    fn profile() -> Profile {
153        Profile {
154            account: "work".to_owned(),
155            org_id: "12345".to_owned(),
156            org_kind: OrgKind::Cloud,
157            default_queue: Some("PROJ".to_owned()),
158            display: Display::default(),
159        }
160    }
161
162    /// The config is a file people write by hand, and the docs tell them to.
163    /// Switching the default must not cost them their comments.
164    #[test]
165    fn setting_the_default_keeps_what_was_written_around_it() {
166        let dir = tempfile::tempdir().expect("temp dir");
167        let path = dir.path().join("config.toml");
168        std::fs::write(
169            &path,
170            "# my notes\ndefault_profile = \"work\"\n\n[profiles.home]\naccount = \"me\"\n",
171        )
172        .expect("write");
173
174        let written = set_default(&path, "home").expect("set default");
175
176        assert!(written.contains("# my notes"));
177        assert!(written.contains(r#"default_profile = "home""#));
178        assert!(written.contains("[profiles.home]"));
179    }
180
181    #[test]
182    fn writes_a_file_that_did_not_exist() {
183        let dir = tempfile::tempdir().expect("temp dir");
184        let path = dir.path().join("nested").join("config.toml");
185
186        let written = upsert(
187            &path,
188            "work",
189            Some("main"),
190            Some(("work", &profile())),
191            true,
192        )
193        .expect("written");
194
195        assert!(written.contains("[accounts.work]"));
196        assert!(written.contains("[profiles.work]"));
197        assert!(written.contains(r#"org_kind = "cloud""#));
198        assert!(written.contains(r#"default_profile = "work""#));
199        assert!(path.exists());
200    }
201
202    /// People hand-write this file; the docs tell them to. An edit must not eat
203    /// what they wrote around it.
204    #[test]
205    fn keeps_comments_and_unrelated_entries() {
206        let dir = tempfile::tempdir().expect("temp dir");
207        let path = dir.path().join("config.toml");
208        std::fs::write(
209            &path,
210            r#"# my notes about which org is which
211default_profile = "other"
212
213[accounts.personal]
214description = "everyday login"
215
216[profiles.other]
217account = "personal"
218org_id = "98765"
219org_kind = "yandex360"
220"#,
221        )
222        .expect("write");
223
224        let written = upsert(
225            &path,
226            "work",
227            Some("admin"),
228            Some(("work", &profile())),
229            false,
230        )
231        .expect("written");
232
233        assert!(written.contains("# my notes about which org is which"));
234        assert!(written.contains("[accounts.personal]"));
235        assert!(written.contains("[profiles.other]"));
236        assert!(written.contains("[profiles.work]"));
237        // An existing default is not moved unless asked.
238        assert!(written.contains(r#"default_profile = "other""#));
239    }
240
241    #[test]
242    fn updating_an_existing_profile_replaces_its_fields() {
243        let dir = tempfile::tempdir().expect("temp dir");
244        let path = dir.path().join("config.toml");
245        upsert(&path, "work", None, Some(("work", &profile())), true).expect("first");
246
247        let mut moved = profile();
248        moved.org_id = "999".to_owned();
249        moved.org_kind = OrgKind::Yandex360;
250        moved.default_queue = None;
251        let written = upsert(&path, "work", None, Some(("work", &moved)), false).expect("second");
252
253        assert!(written.contains(r#"org_id = "999""#));
254        assert!(written.contains(r#"org_kind = "yandex360""#));
255        assert!(!written.contains("default_queue"));
256        assert_eq!(written.matches("[profiles.work]").count(), 1);
257    }
258
259    #[test]
260    fn a_broken_file_is_reported_rather_than_overwritten() {
261        let dir = tempfile::tempdir().expect("temp dir");
262        let path = dir.path().join("config.toml");
263        std::fs::write(&path, "this is not [[[ toml").expect("write");
264
265        assert!(upsert(&path, "work", None, None, false).is_err());
266        assert_eq!(
267            std::fs::read_to_string(&path).expect("still there"),
268            "this is not [[[ toml"
269        );
270    }
271}