use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use toml_edit::{Array, DocumentMut, Item, Value};
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("config I/O error: {0}")]
Io(#[from] io::Error),
#[error("config parse error: {0}")]
Parse(#[from] toml::de::Error),
#[error("config edit error: {0}")]
Edit(#[from] toml_edit::TomlError),
#[error("config serialize error: {0}")]
Serialize(#[from] toml::ser::Error),
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct Config {
pub recipes: Recipes,
pub paths: Paths,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct Recipes {
pub dirs: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct Paths {
pub global_justfile: Option<PathBuf>,
}
pub fn tilde_expand(input: &str) -> Result<PathBuf, ConfigError> {
if input == "~" {
let home = dirs::home_dir().ok_or_else(|| {
ConfigError::Io(io::Error::new(io::ErrorKind::NotFound, "HOME not set"))
})?;
Ok(home)
} else if let Some(rest) = input.strip_prefix("~/") {
let home = dirs::home_dir().ok_or_else(|| {
ConfigError::Io(io::Error::new(io::ErrorKind::NotFound, "HOME not set"))
})?;
Ok(home.join(rest))
} else {
Ok(PathBuf::from(input))
}
}
pub fn user_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".config/lds/config.toml"))
}
impl Config {
pub fn load(path: &Path) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
pub fn load_or_default() -> Self {
let Some(path) = user_config_path() else {
return Self::default();
};
match Self::load(&path) {
Ok(cfg) => cfg,
Err(ConfigError::Io(e)) if e.kind() == io::ErrorKind::NotFound => Self::default(),
Err(e) => {
tracing::warn!("failed to load config from {}: {}", path.display(), e);
Self::default()
}
}
}
pub fn save(path: &Path, dirs: &[PathBuf]) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let existing = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(ConfigError::Io(e)),
};
let mut doc: DocumentMut = existing.parse::<DocumentMut>()?;
let mut arr = Array::new();
for dir in dirs {
arr.push(dir.to_string_lossy().as_ref());
}
if !doc.contains_table("recipes") {
doc["recipes"] = toml_edit::table();
}
doc["recipes"]["dirs"] = Item::Value(Value::Array(arr));
std::fs::write(path, doc.to_string())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_round_trip_load_save() {
let dir = TempDir::new().unwrap(); let path = dir.path().join("config.toml");
let other_root = TempDir::new().unwrap();
let dirs_in = vec![
dir.path().join("shared-recipes"),
other_root.path().join("team-recipes"),
];
Config::save(&path, &dirs_in).expect("save should succeed");
let cfg = Config::load(&path).expect("load should succeed");
assert_eq!(cfg.recipes.dirs, dirs_in);
}
#[test]
fn test_load_or_default_missing_file() {
let dir = TempDir::new().unwrap(); let path = dir.path().join("nonexistent/config.toml");
match Config::load(&path) {
Err(ConfigError::Io(e)) => {
assert_eq!(e.kind(), io::ErrorKind::NotFound);
}
other => panic!("expected Io(NotFound), got {:?}", other),
}
}
#[test]
fn test_user_config_path_under_home() {
let Some(home) = dirs::home_dir() else {
return;
};
let path = user_config_path().expect("home dir is available in this test branch");
assert_eq!(path, home.join(".config/lds/config.toml"));
}
#[test]
fn test_tilde_expand_tilde_slash() {
if dirs::home_dir().is_none() {
return;
}
let result = tilde_expand("~/foo/bar").expect("tilde_expand should succeed");
let home = dirs::home_dir().unwrap(); assert_eq!(result, home.join("foo/bar"));
}
#[test]
fn test_tilde_expand_bare_tilde() {
if dirs::home_dir().is_none() {
return;
}
let result = tilde_expand("~").expect("bare tilde should expand");
let home = dirs::home_dir().unwrap(); assert_eq!(result, home);
}
#[test]
fn test_save_empty_dirs() {
let dir = TempDir::new().unwrap(); let path = dir.path().join("config.toml");
Config::save(&path, &[]).expect("save should succeed");
let cfg = Config::load(&path).expect("load should succeed");
assert!(cfg.recipes.dirs.is_empty());
}
#[test]
fn test_load_or_default_does_not_panic_on_missing() {
let _cfg = Config::load_or_default();
}
#[test]
fn test_tilde_expand_no_tilde() {
let result = tilde_expand("/absolute/path").expect("should succeed");
assert_eq!(result, PathBuf::from("/absolute/path"));
}
#[test]
fn test_tilde_expand_relative() {
let result = tilde_expand("relative/path").expect("should succeed");
assert_eq!(result, PathBuf::from("relative/path"));
}
#[test]
fn test_load_empty_file() {
let dir = TempDir::new().unwrap(); let path = dir.path().join("config.toml");
fs::write(&path, "").unwrap();
let cfg = Config::load(&path).expect("empty file should parse as default");
assert!(cfg.recipes.dirs.is_empty());
assert!(cfg.paths.global_justfile.is_none());
}
#[test]
fn test_load_partial_file_no_recipes() {
let dir = TempDir::new().unwrap(); let path = dir.path().join("config.toml");
fs::write(&path, "[paths]\nglobal_justfile = \"/etc/lds/justfile\"\n").unwrap();
let cfg = Config::load(&path).expect("partial file should parse");
assert!(
cfg.recipes.dirs.is_empty(),
"missing [recipes] should default to empty"
);
assert_eq!(
cfg.paths.global_justfile,
Some(PathBuf::from("/etc/lds/justfile"))
);
}
#[test]
fn test_load_ignores_route_and_export_sections() {
let dir = TempDir::new().unwrap(); let path = dir.path().join("config.toml");
fs::write(
&path,
r#"
[recipes]
dirs = ["/opt/shared-recipes"]
[[route]]
name = "outline"
command = "outline-mcp"
[[export]]
route = "outline"
tools = ["snapshot_create"]
"#,
)
.unwrap();
let cfg = Config::load(&path).expect("route/export sections must not fail Config parsing");
assert_eq!(cfg.recipes.dirs, vec![PathBuf::from("/opt/shared-recipes")]);
}
#[test]
fn test_load_nonexistent_returns_io_not_found() {
let result = Config::load(Path::new("/nonexistent/path/config.toml"));
match result {
Err(ConfigError::Io(e)) => {
assert_eq!(e.kind(), io::ErrorKind::NotFound);
}
other => panic!("expected Io(NotFound), got {:?}", other),
}
}
#[test]
fn test_load_malformed_toml_returns_parse_error() {
let dir = TempDir::new().unwrap(); let path = dir.path().join("config.toml");
fs::write(&path, "this is not = valid toml [\n").unwrap();
let result = Config::load(&path);
assert!(
matches!(result, Err(ConfigError::Parse(_))),
"malformed TOML should yield Parse error, got {:?}",
result
);
}
#[test]
fn test_save_preserves_comments_and_other_sections() {
let dir = TempDir::new().unwrap(); let path = dir.path().join("config.toml");
let initial = r#"# This is a user comment that must survive.
[recipes]
dirs = []
[paths]
global_justfile = "/etc/lds/justfile"
"#;
fs::write(&path, initial).unwrap();
let new_dirs = vec![PathBuf::from("/opt/recipes")];
Config::save(&path, &new_dirs).expect("save should succeed");
let saved = fs::read_to_string(&path).unwrap();
assert!(
saved.contains("# This is a user comment that must survive."),
"comment was not preserved:\n{}",
saved
);
assert!(
saved.contains("[paths]"),
"[paths] section was not preserved:\n{}",
saved
);
assert!(
saved.contains("global_justfile"),
"global_justfile key was not preserved:\n{}",
saved
);
let cfg = Config::load(&path).expect("load after save should succeed");
assert_eq!(cfg.recipes.dirs, new_dirs);
assert!(
!saved.contains('~'),
"tilde literal found on disk — crux 2 violation:\n{}",
saved
);
}
#[test]
fn test_save_does_not_write_tilde_literal() {
if dirs::home_dir().is_none() {
return;
}
let dir = TempDir::new().unwrap(); let path = dir.path().join("config.toml");
let raw = "~/my-recipes";
let expanded = tilde_expand(raw).expect("tilde_expand should succeed");
assert!(
!expanded.to_string_lossy().contains('~'),
"expanded path must not contain tilde"
);
Config::save(&path, &[expanded]).expect("save should succeed");
let saved = fs::read_to_string(&path).unwrap(); assert!(
!saved.contains('~'),
"tilde literal found on disk after save — crux 2 violation:\n{}",
saved
);
}
}