use std::path::{Path, PathBuf};
pub const FILE_NAME: &str = "config.toml";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Settings {
pub data_dir: Option<PathBuf>,
pub scan_root: Option<PathBuf>,
pub cap: Option<usize>,
pub harness: Option<String>,
pub path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Problem {
Unreadable {
path: PathBuf,
why: String,
},
Malformed {
line: usize,
},
UnknownKey {
line: usize,
key: String,
},
BadValue {
line: usize,
key: String,
value: String,
},
}
impl std::fmt::Display for Problem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Problem::Unreadable { path, why } => {
write!(f, "could not read {}: {why}", path.display())
}
Problem::Malformed { line } => {
write!(f, "{FILE_NAME} line {line}: not `key = value`")
}
Problem::UnknownKey { line, key } => write!(
f,
"{FILE_NAME} line {line}: no option is called `{key}` — known keys are {}",
KEYS.join(", ")
),
Problem::BadValue { line, key, value } => {
write!(f, "{FILE_NAME} line {line}: `{key}` cannot be `{value}`")
}
}
}
}
const KEYS: &[&str] = &["data-dir", "scan-root", "cap", "harness"];
impl Settings {
pub fn load(explicit: Option<&Path>) -> (Self, Vec<Problem>) {
let path = explicit.map(Path::to_path_buf).unwrap_or_else(default_path);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return (Self::default(), Vec::new());
}
Err(err) => {
return (
Self::default(),
vec![Problem::Unreadable {
path,
why: err.to_string(),
}],
);
}
};
let (mut settings, problems) = parse(&text);
settings.path = Some(path);
(settings, problems)
}
}
pub fn default_path() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
&& !xdg.is_empty()
{
return PathBuf::from(xdg).join("marver").join(FILE_NAME);
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home)
.join(".config")
.join("marver")
.join(FILE_NAME)
}
fn parse(text: &str) -> (Settings, Vec<Problem>) {
let mut settings = Settings::default();
let mut problems = Vec::new();
for (index, raw) in text.lines().enumerate() {
let line = index + 1;
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('[') {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
problems.push(Problem::Malformed { line });
continue;
};
let key = key.trim();
let value = unquote(value.trim());
if KEYS.contains(&key) && value.is_empty() {
problems.push(Problem::BadValue {
line,
key: key.to_string(),
value,
});
continue;
}
match key {
"data-dir" => settings.data_dir = Some(expand(&value)),
"scan-root" => settings.scan_root = Some(expand(&value)),
"cap" => match value.parse() {
Ok(cap) => settings.cap = Some(cap),
Err(_) => problems.push(Problem::BadValue {
line,
key: key.to_string(),
value,
}),
},
"harness" => settings.harness = Some(value),
_ => problems.push(Problem::UnknownKey {
line,
key: key.to_string(),
}),
}
}
(settings, problems)
}
fn unquote(value: &str) -> String {
for quote in ['"', '\''] {
if let Some(rest) = value.strip_prefix(quote) {
let Some((inner, after)) = rest.rsplit_once(quote) else {
return rest.to_string();
};
let after = after.trim();
if after.is_empty() || after.starts_with('#') {
return inner.to_string();
}
return rest.to_string();
}
}
match value.split_once('#') {
Some((before, _)) => before.trim_end().to_string(),
None => value.to_string(),
}
}
fn expand(value: &str) -> PathBuf {
let Some(rest) = value.strip_prefix('~') else {
return PathBuf::from(value);
};
if !(rest.is_empty() || rest.starts_with('/')) {
return PathBuf::from(value);
}
let Ok(home) = std::env::var("HOME") else {
return PathBuf::from(value);
};
PathBuf::from(home).join(rest.trim_start_matches('/'))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn an_empty_file_asks_for_nothing() {
let (settings, problems) = parse("");
assert_eq!(settings, Settings::default());
assert!(problems.is_empty());
}
#[test]
fn every_option_can_be_set() {
let (settings, problems) = parse(
"data-dir = \"/tmp/data\"\nscan-root = \"/tmp/code\"\ncap = 3\nharness = \"codex\"\n",
);
assert!(problems.is_empty(), "{problems:?}");
assert_eq!(settings.data_dir, Some(PathBuf::from("/tmp/data")));
assert_eq!(settings.scan_root, Some(PathBuf::from("/tmp/code")));
assert_eq!(settings.cap, Some(3));
assert_eq!(settings.harness.as_deref(), Some("codex"));
}
#[test]
fn comments_and_blank_lines_are_skipped() {
let (settings, problems) = parse("# marver\n\n \ncap = 2 # how many at once\n");
assert!(problems.is_empty(), "{problems:?}");
assert_eq!(settings.cap, Some(2));
}
#[test]
fn quotes_are_optional_and_spacing_is_free() {
let (settings, _) = parse("cap=1\nscan-root = /tmp/code \nharness = 'codex'\n");
assert_eq!(settings.cap, Some(1));
assert_eq!(settings.scan_root, Some(PathBuf::from("/tmp/code")));
assert_eq!(settings.harness.as_deref(), Some("codex"));
}
#[test]
fn a_hash_inside_a_quoted_value_is_kept() {
let (settings, problems) = parse("harness = \"x:prog --tag '#1'\"\n");
assert!(problems.is_empty(), "{problems:?}");
assert_eq!(settings.harness.as_deref(), Some("x:prog --tag '#1'"));
}
#[test]
fn a_key_with_a_typo_in_it_is_said_out_loud() {
let (settings, problems) = parse("scanroot = \"/tmp\"\n");
assert_eq!(settings.scan_root, None);
assert_eq!(
problems,
vec![Problem::UnknownKey {
line: 1,
key: "scanroot".to_string()
}]
);
let said = problems[0].to_string();
assert!(said.contains("scanroot"), "{said}");
assert!(
said.contains("scan-root"),
"it must say what was meant: {said}"
);
}
#[test]
fn a_bad_value_keeps_the_default_and_complains() {
let (settings, problems) = parse("cap = lots\n");
assert_eq!(settings.cap, None, "the default still applies");
assert_eq!(
problems,
vec![Problem::BadValue {
line: 1,
key: "cap".to_string(),
value: "lots".to_string()
}]
);
}
#[test]
fn a_line_that_is_not_a_pair_is_reported_with_its_number() {
let (_, problems) = parse("cap = 2\nnonsense\nharness = \"claude\"\n");
assert_eq!(problems, vec![Problem::Malformed { line: 2 }]);
}
#[test]
fn one_bad_line_does_not_cost_the_others() {
let (settings, problems) = parse("cap = 2\nwat = 1\nharness = \"codex\"\n");
assert_eq!(settings.cap, Some(2));
assert_eq!(settings.harness.as_deref(), Some("codex"));
assert_eq!(problems.len(), 1);
}
#[test]
fn a_table_header_is_skipped_rather_than_refused() {
let (settings, problems) = parse("[marver]\ncap = 2\n");
assert!(problems.is_empty(), "{problems:?}");
assert_eq!(settings.cap, Some(2));
}
#[test]
fn a_tilde_is_expanded_because_no_shell_will_do_it() {
let _env = crate::env_lock();
let before = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", "/home/someone") };
let (settings, _) = parse("data-dir = \"~/marver\"\nscan-root = \"~\"\n");
assert_eq!(
settings.data_dir,
Some(PathBuf::from("/home/someone/marver"))
);
assert_eq!(settings.scan_root, Some(PathBuf::from("/home/someone")));
let (other, _) = parse("data-dir = \"~root/x\"\n");
assert_eq!(other.data_dir, Some(PathBuf::from("~root/x")));
match before {
Some(home) => unsafe { std::env::set_var("HOME", home) },
None => unsafe { std::env::remove_var("HOME") },
}
}
#[test]
fn a_file_that_is_not_there_says_nothing() {
let tmp = TempDir::new().unwrap();
let (settings, problems) = Settings::load(Some(&tmp.path().join("nope.toml")));
assert_eq!(settings.path, None, "nothing was read");
assert!(problems.is_empty(), "and nothing is wrong with that");
}
#[test]
fn a_file_that_is_there_records_where_it_came_from() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(FILE_NAME);
std::fs::write(&path, "cap = 4\n").unwrap();
let (settings, problems) = Settings::load(Some(&path));
assert!(problems.is_empty(), "{problems:?}");
assert_eq!(settings.cap, Some(4));
assert_eq!(settings.path.as_deref(), Some(path.as_path()));
}
#[test]
fn a_directory_where_a_file_was_asked_for_is_a_problem() {
let tmp = TempDir::new().unwrap();
let (settings, problems) = Settings::load(Some(tmp.path()));
assert_eq!(settings, Settings::default());
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(matches!(problems[0], Problem::Unreadable { .. }));
}
#[test]
fn a_key_left_blank_is_refused_rather_than_applied() {
let (settings, problems) = parse("data-dir =\nscan-root = \"\"\n");
assert_eq!(settings, Settings::default());
assert_eq!(problems.len(), 2, "{problems:?}");
assert!(
problems
.iter()
.all(|p| matches!(p, Problem::BadValue { .. }))
);
}
#[test]
fn a_blank_value_under_an_unknown_key_is_still_an_unknown_key() {
let (_, problems) = parse("data-directory =\n");
assert!(
matches!(&problems[..], [Problem::UnknownKey { key, .. }] if key == "data-directory"),
"{problems:?}"
);
}
#[test]
fn a_quoted_value_keeps_everything_inside_its_quotes() {
let (settings, problems) = parse("harness = \"claude --settings \"a b.json\"\"\n");
assert!(problems.is_empty(), "{problems:?}");
assert_eq!(
settings.harness.as_deref(),
Some("claude --settings \"a b.json\"")
);
}
#[test]
fn a_comment_after_a_quoted_value_is_still_a_comment() {
let (settings, _) = parse("harness = \"codex\" # the other one\n");
assert_eq!(settings.harness.as_deref(), Some("codex"));
}
#[test]
fn the_default_path_follows_xdg_then_home() {
let _env = crate::env_lock();
let config_home = std::env::var("XDG_CONFIG_HOME").ok();
let home = std::env::var("HOME").ok();
unsafe { std::env::set_var("XDG_CONFIG_HOME", "/xdg") };
assert_eq!(default_path(), PathBuf::from("/xdg/marver/config.toml"));
unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
unsafe { std::env::set_var("HOME", "/home/someone") };
assert_eq!(
default_path(),
PathBuf::from("/home/someone/.config/marver/config.toml")
);
unsafe { std::env::set_var("XDG_CONFIG_HOME", "") };
assert_eq!(
default_path(),
PathBuf::from("/home/someone/.config/marver/config.toml")
);
match config_home {
Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) },
None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
}
match home {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}