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(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 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
90pub 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
115fn 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
132fn 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 #[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 #[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 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}