use std::{
ffi::OsString,
fs,
path::{Path, PathBuf},
};
use clap::CommandFactory;
use toml::Value;
use crate::args::Args;
pub fn default_path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| home().map(|home| home.join(".config")))?;
Some(base.join("topos").join("config.toml"))
}
const DEFAULT_CONFIG: &str = include_str!("../defaults/config.toml");
const DEFAULT_QUERIES: &str = include_str!("../defaults/queries.toml");
fn create_defaults(config: &Path) {
let Some(dir) = config.parent() else { return };
let queries = crate::queries::default_path();
let missing: Vec<_> = [
(Some(config.to_path_buf()), DEFAULT_CONFIG),
(queries, DEFAULT_QUERIES),
]
.into_iter()
.filter_map(|(path, text)| Some((path?, text)))
.filter(|(path, _)| !path.exists())
.collect();
if missing.is_empty() || fs::create_dir_all(dir).is_err() {
return;
}
for (path, text) in missing {
if let Ok(mut file) = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
let _ = std::io::Write::write_all(&mut file, text.as_bytes());
}
}
}
fn home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
fn config_flag(args: &[OsString]) -> Option<PathBuf> {
args.iter().enumerate().find_map(|(idx, arg)| {
let arg = arg.to_str()?;
if arg == "--config" {
args.get(idx + 1).map(PathBuf::from)
} else {
arg.strip_prefix("--config=").map(PathBuf::from)
}
})
}
pub fn with_defaults(args: Vec<OsString>) -> Result<Vec<OsString>, String> {
if args.iter().any(|arg| arg == "--no-config") {
return Ok(args);
}
let path = match config_flag(&args) {
Some(path) if !path.exists() => {
return Err(format!("{}: config file not found", path.display()));
}
Some(path) => path,
None => match default_path()
.inspect(|path| create_defaults(path))
.filter(|path| path.exists())
{
Some(path) => path,
None => return Ok(args),
},
};
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let defaults = parse(&text).map_err(|e| format!("{}: {e}", path.display()))?;
let mut args = args.into_iter();
Ok(args
.next()
.into_iter()
.chain(defaults)
.chain(args)
.collect())
}
fn parse(text: &str) -> Result<Vec<OsString>, String> {
let table: toml::Table = text.parse().map_err(|e: toml::de::Error| e.to_string())?;
let command = Args::command();
let mut args = vec![];
for (key, value) in table {
let given = key.replace('_', "-");
let name = command
.get_arguments()
.find(|arg| {
arg.get_long() == Some(given.as_str())
|| arg
.get_all_aliases()
.is_some_and(|aliases| aliases.contains(&given.as_str()))
})
.and_then(|arg| arg.get_long())
.map(str::to_string);
let Some(name) = name.filter(|name| name != "no-config" && name != "config") else {
return Err(format!("unknown option `{key}`"));
};
let takes_bool = command
.get_arguments()
.find(|arg| arg.get_long() == Some(name.as_str()))
.is_some_and(|arg| arg.get_action().takes_values());
let flag = OsString::from(format!("--{name}"));
let values = match value {
Value::Array(values) => values,
value => vec![value],
};
for value in values {
match value {
Value::Boolean(b) if takes_bool => {
args.push(OsString::from(format!("--{name}={b}")))
}
Value::Boolean(true) => args.push(flag.clone()),
Value::Boolean(false) => {}
Value::String(s) => args.extend([flag.clone(), expand_home(&s)]),
Value::Table(table) => args.extend([
flag.clone(),
serde_json::to_string(&table)
.map_err(|e| format!("`{key}`: {e}"))?
.into(),
]),
Value::Integer(n) => args.extend([flag.clone(), n.to_string().into()]),
other => return Err(format!("unsupported value for `{key}`: {other}")),
}
}
}
Ok(args)
}
fn expand_home(value: &str) -> OsString {
match (value.strip_prefix("~/"), home()) {
(Some(rest), Some(home)) => home.join(rest).into_os_string(),
_ => value.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn options_become_arguments() {
let args = parse(
"mode = \"grouped\"\ncache = true\nsort = false\nexclude_book = [\"Jude\", \"Ruth\"]\nafter-context = 2\n",
)
.unwrap();
let args: Vec<_> = args.iter().map(|a| a.to_str().unwrap()).collect();
assert_eq!(
args,
[
"--after-context",
"2",
"--cache",
"--exclude-book",
"Jude",
"--exclude-book",
"Ruth",
"--mode",
"grouped"
]
);
}
#[test]
fn rejects_unknown_options() {
assert_eq!(
parse("colour = \"always\"").unwrap_err(),
"unknown option `colour`"
);
assert!(parse("no-config = true").is_err());
assert!(parse("config = \"other.toml\"").is_err());
assert!(parse("mode = [1.5]").is_err());
assert!(parse("not toml").is_err());
}
}