use std::fs;
use std::io::Write;
use std::path::Path;
use anyhow::{anyhow, bail, Context, Result};
use tempfile::NamedTempFile;
use toml_edit::{DocumentMut, Item, Table, Value};
pub fn get(config_path: &Path, key: &str) -> Result<()> {
let doc = read_doc(config_path)?;
println!("{}", get_value(&doc, key)?);
Ok(())
}
pub fn set(config_path: &Path, key: &str, value: &str) -> Result<()> {
let mut doc = read_doc(config_path)?;
set_value(&mut doc, key, value)?;
write_atomically(config_path, doc.to_string().as_bytes())?;
println!("set {key} = {value}");
Ok(())
}
pub fn edit(config_path: &Path) -> Result<()> {
if !config_path.exists() {
bail!(
"no config at {}; run `navi init` first",
config_path.display()
);
}
crate::editor::open(config_path)?;
if let Err(e) = read_doc(config_path) {
eprintln!("warning: {} no longer parses: {e:#}", config_path.display());
}
Ok(())
}
fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
let dir = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let mut tmp = NamedTempFile::new_in(dir)
.with_context(|| format!("creating temp file in {}", dir.display()))?;
tmp.write_all(bytes)
.with_context(|| format!("writing {}", path.display()))?;
tmp.persist(path)
.with_context(|| format!("replacing {}", path.display()))?;
Ok(())
}
fn read_doc(config_path: &Path) -> Result<DocumentMut> {
if !config_path.exists() {
bail!(
"no config at {}; run `navi init` first",
config_path.display()
);
}
let text = fs::read_to_string(config_path)
.with_context(|| format!("reading {}", config_path.display()))?;
text.parse::<DocumentMut>()
.with_context(|| format!("parsing {}", config_path.display()))
}
fn get_value(doc: &DocumentMut, key: &str) -> Result<String> {
let mut parts = key.split('.');
let first = parts.next().filter(|s| !s.is_empty());
let mut item = first
.and_then(|p| doc.as_table().get(p))
.filter(|i| !i.is_none())
.ok_or_else(|| anyhow!("no config value at `{key}`"))?;
for part in parts {
item = item
.get(part)
.filter(|i| !i.is_none())
.ok_or_else(|| anyhow!("no config value at `{key}`"))?;
}
Ok(render_item(item))
}
fn set_value(doc: &mut DocumentMut, key: &str, value: &str) -> Result<()> {
let parts: Vec<&str> = key.split('.').filter(|s| !s.is_empty()).collect();
let Some((leaf, parents)) = parts.split_last() else {
bail!("empty config key");
};
let mut table: &mut Table = doc.as_table_mut();
for part in parents {
table = table
.get_mut(part)
.and_then(Item::as_table_mut)
.ok_or_else(|| anyhow!("no config section `{part}`; add it to config.toml first"))?;
}
if !table.contains_key(leaf) {
bail!("no config key `{key}`; check the spelling or add it to config.toml first");
}
table[leaf] = infer_value(value);
Ok(())
}
fn infer_value(s: &str) -> Item {
if let Ok(b) = s.parse::<bool>() {
toml_edit::value(b)
} else if let Ok(i) = s.parse::<i64>() {
toml_edit::value(i)
} else {
toml_edit::value(s)
}
}
fn render_item(item: &Item) -> String {
match item.as_value() {
Some(Value::String(s)) => s.value().clone(),
Some(v) => v.to_string().trim().to_string(),
None => item.to_string().trim().to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
# top comment
[general]
# how often to poll
poll_interval_secs = 60
[github]
enabled = true
token_env = \"NAVI_GITHUB_TOKEN\"
";
fn doc() -> DocumentMut {
SAMPLE.parse().unwrap()
}
#[test]
fn get_reads_scalars_without_quotes() {
assert_eq!(
get_value(&doc(), "general.poll_interval_secs").unwrap(),
"60"
);
assert_eq!(get_value(&doc(), "github.enabled").unwrap(), "true");
assert_eq!(
get_value(&doc(), "github.token_env").unwrap(),
"NAVI_GITHUB_TOKEN"
);
}
#[test]
fn get_unknown_key_errors() {
assert!(get_value(&doc(), "github.nope").is_err());
assert!(get_value(&doc(), "nosuch.section").is_err());
}
#[test]
fn set_preserves_comments_and_infers_types() {
let mut d = doc();
set_value(&mut d, "general.poll_interval_secs", "30").unwrap();
set_value(&mut d, "github.enabled", "false").unwrap();
let out = d.to_string();
assert!(out.contains("# top comment"));
assert!(out.contains("# how often to poll"));
assert!(out.contains("poll_interval_secs = 30"));
assert!(out.contains("enabled = false"));
}
#[test]
fn set_into_a_missing_section_errors() {
let mut d = doc();
assert!(set_value(&mut d, "discord.enabled", "true").is_err());
}
#[test]
fn set_of_a_misspelled_key_errors() {
let mut d = doc();
assert!(set_value(&mut d, "github.enabeld", "true").is_err());
assert!(!d.to_string().contains("enabeld"));
}
}