use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{Error, Result};
#[derive(Debug, Serialize, Deserialize)]
pub struct RepoConfig {
#[serde(default, skip_serializing_if = "IgnoreConfig::is_empty")]
pub ignore: IgnoreConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct IgnoreConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub patterns: Vec<String>,
}
impl IgnoreConfig {
#[must_use]
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
}
}
impl RepoConfig {
pub fn from_toml(s: &str) -> Result<Self> {
toml::from_str(s)
.map_err(|e| Error::Other(anyhow::anyhow!("failed to deserialize config: {e}")))
}
pub fn load(path: &Path) -> Result<Self> {
let file = path.join("config.toml");
let contents = std::fs::read_to_string(&file).map_err(|source| Error::Io {
path: file.display().to_string(),
source,
})?;
Self::from_toml(&contents)
}
}
#[must_use]
pub fn find_config(start: &Path) -> Option<(PathBuf, RepoConfig)> {
let mut current = start.to_path_buf();
loop {
let candidate = current.join(".ripvec");
let config_file = candidate.join("config.toml");
if config_file.exists() {
return RepoConfig::load(&candidate)
.ok()
.map(|config| (candidate, config));
}
match current.parent() {
Some(parent) => current = parent.to_path_buf(),
None => return None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn round_trip_toml_ignore_only() {
let toml_str = "[ignore]\npatterns = [\"*.jsonl\", \"docs/generated/**\"]\n";
let cfg = RepoConfig::from_toml(toml_str).expect("deserialize");
assert_eq!(cfg.ignore.patterns, ["*.jsonl", "docs/generated/**"]);
}
#[test]
fn missing_ignore_section_defaults_to_empty_patterns() {
let cfg_str =
"[cache]\nlocal = true\nmodel = \"BAAI/bge-small-en-v1.5\"\nversion = \"3\"\n";
let cfg = RepoConfig::from_toml(cfg_str).expect("deserialize");
assert!(cfg.ignore.patterns.is_empty());
}
#[test]
fn load_from_disk() {
let dir = TempDir::new().expect("tempdir");
let ripvec_dir = dir.path().join(".ripvec");
std::fs::create_dir_all(&ripvec_dir).expect("mkdir");
let cfg_str = "[ignore]\npatterns = [\"*.log\"]\n";
std::fs::write(ripvec_dir.join("config.toml"), cfg_str).expect("write");
let loaded = RepoConfig::load(&ripvec_dir).expect("load");
assert_eq!(loaded.ignore.patterns, ["*.log"]);
}
#[test]
fn find_config_in_current_dir() {
let dir = TempDir::new().expect("tempdir");
let ripvec_dir = dir.path().join(".ripvec");
std::fs::create_dir_all(&ripvec_dir).expect("mkdir");
std::fs::write(
ripvec_dir.join("config.toml"),
"[ignore]\npatterns = [\"*.tmp\"]\n",
)
.expect("write");
let found = find_config(dir.path());
assert!(found.is_some());
let (_, cfg) = found.unwrap();
assert_eq!(cfg.ignore.patterns, ["*.tmp"]);
}
#[test]
fn find_config_in_parent_dir() {
let dir = TempDir::new().expect("tempdir");
let ripvec_dir = dir.path().join(".ripvec");
std::fs::create_dir_all(&ripvec_dir).expect("mkdir");
std::fs::write(
ripvec_dir.join("config.toml"),
"[ignore]\npatterns = [\"*.tmp\"]\n",
)
.expect("write");
let subdir = dir.path().join("src").join("foo");
std::fs::create_dir_all(&subdir).expect("mkdir");
let found = find_config(&subdir);
assert!(found.is_some(), "should walk up to parent .ripvec");
}
#[test]
fn find_config_not_found() {
let dir = TempDir::new().expect("tempdir");
assert!(find_config(dir.path()).is_none());
}
}