use std::path::Path;
use toml_edit::{DocumentMut, Item, Table, value};
use crate::config::{OrgKind, Profile};
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("could not read the configuration file")]
Read(#[source] std::io::Error),
#[error("could not write the configuration file")]
Write(#[source] std::io::Error),
#[error("the configuration file is not valid TOML; fix or move it first")]
Parse(#[from] toml_edit::TomlError),
}
pub fn upsert(
path: &Path,
account: &str,
description: Option<&str>,
profile: Option<(&str, &Profile)>,
make_default: bool,
) -> Result<String, StoreError> {
let existing = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(StoreError::Read(error)),
};
let mut document: DocumentMut = existing.parse()?;
let accounts = implicit_table(&mut document, "accounts");
let entry = accounts
.entry(account)
.or_insert_with(|| Item::Table(Table::new()));
if let (Some(table), Some(description)) = (entry.as_table_mut(), description) {
table["description"] = value(description);
}
if let Some((name, profile)) = profile {
let profiles = implicit_table(&mut document, "profiles");
let entry = profiles
.entry(name)
.or_insert_with(|| Item::Table(Table::new()));
if let Some(table) = entry.as_table_mut() {
table["account"] = value(&profile.account);
table["org_id"] = value(&profile.org_id);
table["org_kind"] = value(match profile.org_kind {
OrgKind::Cloud => "cloud",
OrgKind::Yandex360 => "yandex360",
});
match &profile.default_queue {
Some(queue) => table["default_queue"] = value(queue),
None => {
table.remove("default_queue");
}
}
}
if make_default {
document["default_profile"] = value(name);
}
}
let rendered = document.to_string();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(StoreError::Write)?;
}
write_private(path, &rendered).map_err(StoreError::Write)?;
Ok(rendered)
}
pub fn set_default(path: &Path, name: &str) -> Result<String, StoreError> {
let existing = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(StoreError::Read(error)),
};
let mut document: DocumentMut = existing.parse()?;
document["default_profile"] = value(name);
let rendered = document.to_string();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(StoreError::Write)?;
}
write_private(path, &rendered).map_err(StoreError::Write)?;
Ok(rendered)
}
fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
std::fs::write(path, contents)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
fn implicit_table<'a>(document: &'a mut DocumentMut, name: &str) -> &'a mut Table {
let entry = document
.entry(name)
.or_insert_with(|| Item::Table(Table::new()));
if let Some(table) = entry.as_table_mut() {
table.set_implicit(true);
}
entry
.as_table_mut()
.unwrap_or_else(|| unreachable!("just inserted a table"))
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::config::Display;
fn profile() -> Profile {
Profile {
account: "work".to_owned(),
org_id: "12345".to_owned(),
org_kind: OrgKind::Cloud,
default_queue: Some("PROJ".to_owned()),
display: Display::default(),
}
}
#[test]
fn setting_the_default_keeps_what_was_written_around_it() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"# my notes\ndefault_profile = \"work\"\n\n[profiles.home]\naccount = \"me\"\n",
)
.expect("write");
let written = set_default(&path, "home").expect("set default");
assert!(written.contains("# my notes"));
assert!(written.contains(r#"default_profile = "home""#));
assert!(written.contains("[profiles.home]"));
}
#[test]
fn writes_a_file_that_did_not_exist() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("nested").join("config.toml");
let written = upsert(
&path,
"work",
Some("main"),
Some(("work", &profile())),
true,
)
.expect("written");
assert!(written.contains("[accounts.work]"));
assert!(written.contains("[profiles.work]"));
assert!(written.contains(r#"org_kind = "cloud""#));
assert!(written.contains(r#"default_profile = "work""#));
assert!(path.exists());
}
#[test]
fn keeps_comments_and_unrelated_entries() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"# my notes about which org is which
default_profile = "other"
[accounts.personal]
description = "everyday login"
[profiles.other]
account = "personal"
org_id = "98765"
org_kind = "yandex360"
"#,
)
.expect("write");
let written = upsert(
&path,
"work",
Some("admin"),
Some(("work", &profile())),
false,
)
.expect("written");
assert!(written.contains("# my notes about which org is which"));
assert!(written.contains("[accounts.personal]"));
assert!(written.contains("[profiles.other]"));
assert!(written.contains("[profiles.work]"));
assert!(written.contains(r#"default_profile = "other""#));
}
#[test]
fn updating_an_existing_profile_replaces_its_fields() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("config.toml");
upsert(&path, "work", None, Some(("work", &profile())), true).expect("first");
let mut moved = profile();
moved.org_id = "999".to_owned();
moved.org_kind = OrgKind::Yandex360;
moved.default_queue = None;
let written = upsert(&path, "work", None, Some(("work", &moved)), false).expect("second");
assert!(written.contains(r#"org_id = "999""#));
assert!(written.contains(r#"org_kind = "yandex360""#));
assert!(!written.contains("default_queue"));
assert_eq!(written.matches("[profiles.work]").count(), 1);
}
#[test]
fn a_broken_file_is_reported_rather_than_overwritten() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("config.toml");
std::fs::write(&path, "this is not [[[ toml").expect("write");
assert!(upsert(&path, "work", None, None, false).is_err());
assert_eq!(
std::fs::read_to_string(&path).expect("still there"),
"this is not [[[ toml"
);
}
}