use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct GlobalConfig {
#[serde(default)]
pub scan_paths: Vec<PathBuf>,
#[serde(default)]
pub collections: Vec<CollectionConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CollectionConfig {
pub name: String,
pub path: PathBuf,
#[serde(default)]
pub extensions: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub domain_terms: Vec<String>,
}
impl GlobalConfig {
pub fn config_path() -> PathBuf {
match dirs::config_dir() {
Some(base) => base.join("trusty-search").join("config.yaml"),
None => PathBuf::from("trusty-search-config.yaml"),
}
}
pub fn load() -> Result<Self> {
let path = Self::config_path();
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let raw = std::fs::read_to_string(path)
.with_context(|| format!("could not read {}", path.display()))?;
if raw.trim().is_empty() {
return Ok(Self::default());
}
let cfg: Self = serde_yml::from_str(&raw)
.with_context(|| format!("could not parse {} as YAML", path.display()))?;
Ok(cfg)
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path();
self.save_to(&path)
}
pub fn save_to(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("could not create {}", parent.display()))?;
}
let yaml = serde_yml::to_string(self).context("could not serialise config as YAML")?;
let tmp = path.with_extension("yaml.tmp");
std::fs::write(&tmp, yaml).with_context(|| format!("could not write {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("could not rename {} to {}", tmp.display(), path.display()))?;
Ok(())
}
pub fn upsert_collection(&mut self, col: CollectionConfig) {
if let Some(slot) = self.collections.iter_mut().find(|c| c.name == col.name) {
*slot = col;
} else {
self.collections.push(col);
}
}
pub fn remove_collection_by_path(&mut self, path: &Path) -> Option<CollectionConfig> {
let target = canonicalise(path);
let idx = self
.collections
.iter()
.position(|c| canonicalise(&c.path) == target)?;
Some(self.collections.remove(idx))
}
}
fn canonicalise(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn unique_tmp(label: &str) -> PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = std::env::temp_dir().join(format!("trusty-config-{label}-{pid}-{nanos}"));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn config_default_is_empty() {
let cfg = GlobalConfig::default();
assert!(cfg.scan_paths.is_empty());
assert!(cfg.collections.is_empty());
}
#[test]
fn config_path_ends_with_expected_segments() {
let p = GlobalConfig::config_path();
let s = p.to_string_lossy();
assert!(
s.ends_with("trusty-search/config.yaml") || s.ends_with("trusty-search-config.yaml"),
"unexpected config path: {s}"
);
}
#[test]
fn load_returns_default_when_missing() {
let dir = unique_tmp("missing");
let path = dir.join("does-not-exist.yaml");
let cfg = GlobalConfig::load_from(&path).unwrap();
assert_eq!(cfg, GlobalConfig::default());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_errors_on_malformed() {
let dir = unique_tmp("malformed");
let path = dir.join("config.yaml");
fs::write(&path, "not: : : valid").unwrap();
let err = GlobalConfig::load_from(&path).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("YAML") || msg.contains("yaml"), "msg={msg}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn roundtrip_preserves_fields() {
let dir = unique_tmp("roundtrip");
let path = dir.join("config.yaml");
let cfg = GlobalConfig {
scan_paths: vec![PathBuf::from("/tmp/projects")],
collections: vec![CollectionConfig {
name: "myproj".into(),
path: PathBuf::from("/tmp/projects/myproj"),
extensions: vec!["rs".into(), "toml".into()],
exclude: vec!["target/".into()],
domain_terms: vec!["embedding".into()],
}],
};
cfg.save_to(&path).unwrap();
let loaded = GlobalConfig::load_from(&path).unwrap();
assert_eq!(cfg, loaded);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn save_creates_parent_dir() {
let dir = unique_tmp("parent");
let path = dir.join("nested").join("inner").join("config.yaml");
GlobalConfig::default().save_to(&path).unwrap();
assert!(path.exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn upsert_replaces_by_name() {
let mut cfg = GlobalConfig::default();
cfg.upsert_collection(CollectionConfig {
name: "a".into(),
path: PathBuf::from("/old"),
extensions: vec![],
exclude: vec![],
domain_terms: vec![],
});
cfg.upsert_collection(CollectionConfig {
name: "a".into(),
path: PathBuf::from("/new"),
extensions: vec!["rs".into()],
exclude: vec![],
domain_terms: vec![],
});
assert_eq!(cfg.collections.len(), 1);
assert_eq!(cfg.collections[0].path, PathBuf::from("/new"));
assert_eq!(cfg.collections[0].extensions, vec!["rs".to_string()]);
}
#[test]
fn upsert_appends_when_absent() {
let mut cfg = GlobalConfig::default();
cfg.upsert_collection(CollectionConfig {
name: "a".into(),
path: PathBuf::from("/a"),
extensions: vec![],
exclude: vec![],
domain_terms: vec![],
});
cfg.upsert_collection(CollectionConfig {
name: "b".into(),
path: PathBuf::from("/b"),
extensions: vec![],
exclude: vec![],
domain_terms: vec![],
});
assert_eq!(cfg.collections.len(), 2);
}
#[test]
fn remove_matches_by_canonical_path() {
let dir = unique_tmp("remove");
let project = dir.join("proj");
fs::create_dir_all(&project).unwrap();
let mut cfg = GlobalConfig::default();
cfg.upsert_collection(CollectionConfig {
name: "proj".into(),
path: project.clone(),
extensions: vec![],
exclude: vec![],
domain_terms: vec![],
});
let removed = cfg.remove_collection_by_path(&project);
assert!(removed.is_some());
assert_eq!(removed.unwrap().name, "proj");
assert!(cfg.collections.is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn remove_returns_none_for_unknown_path() {
let mut cfg = GlobalConfig::default();
cfg.upsert_collection(CollectionConfig {
name: "a".into(),
path: PathBuf::from("/a"),
extensions: vec![],
exclude: vec![],
domain_terms: vec![],
});
assert!(cfg
.remove_collection_by_path(Path::new("/nowhere"))
.is_none());
assert_eq!(cfg.collections.len(), 1);
}
}