use std::path::Path;
use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::Deserialize;
use crate::EngineError;
pub const CONFIG_FILE_NAME: &str = ".differential.toml";
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
#[serde(default)]
classify: RawClassify,
#[serde(default)]
grouping: GroupingConfig,
#[serde(default)]
ordering: toml::Table,
#[serde(default)]
stack: toml::Table,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupingConfig {
#[serde(default)]
pub command: Option<Vec<String>>,
#[serde(default)]
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawClassify {
#[serde(default)]
generated: Vec<String>,
#[serde(default)]
not_generated: Vec<String>,
#[serde(default)]
attributes: Option<Vec<String>>,
}
#[derive(Debug)]
pub struct Config {
pub generated: GlobSet,
pub not_generated: GlobSet,
pub attributes: Vec<String>,
pub grouping: GroupingConfig,
}
impl Default for Config {
fn default() -> Self {
Config {
generated: GlobSet::empty(),
not_generated: GlobSet::empty(),
attributes: vec!["linguist-generated".to_string()],
grouping: GroupingConfig::default(),
}
}
}
impl Config {
pub fn load(repo_root: &Path, explicit: Option<&Path>) -> Result<Config, EngineError> {
let path = match explicit {
Some(p) => p.to_path_buf(),
None => {
let p = repo_root.join(CONFIG_FILE_NAME);
if !p.exists() {
return Ok(Config::default());
}
p
}
};
let text = std::fs::read_to_string(&path).map_err(|e| EngineError::Config {
path: path.display().to_string(),
msg: e.to_string(),
})?;
Self::parse(&text, &path.display().to_string())
}
pub fn parse(text: &str, origin: &str) -> Result<Config, EngineError> {
let raw: RawConfig = toml::from_str(text).map_err(|e| EngineError::Config {
path: origin.to_string(),
msg: e.to_string(),
})?;
let _ = (&raw.ordering, &raw.stack); Ok(Config {
generated: build_globs(&raw.classify.generated, origin)?,
not_generated: build_globs(&raw.classify.not_generated, origin)?,
attributes: raw
.classify
.attributes
.unwrap_or_else(|| vec!["linguist-generated".to_string()]),
grouping: raw.grouping,
})
}
}
fn build_globs(patterns: &[String], origin: &str) -> Result<GlobSet, EngineError> {
let mut b = GlobSetBuilder::new();
for p in patterns {
let glob = Glob::new(p).map_err(|e| EngineError::Config {
path: origin.to_string(),
msg: format!("bad glob {p:?}: {e}"),
})?;
b.add(glob);
}
b.build().map_err(|e| EngineError::Config {
path: origin.to_string(),
msg: e.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_when_empty() {
let c = Config::parse("", "test").unwrap();
assert_eq!(c.attributes, vec!["linguist-generated"]);
assert!(!c.generated.is_match("anything"));
}
#[test]
fn globs_and_overrides() {
let c = Config::parse(
r#"
[classify]
generated = ["**/__snapshots__/**", "migrations/**"]
not_generated = ["important.lock"]
attributes = ["linguist-generated", "custom-generated"]
"#,
"test",
)
.unwrap();
assert!(c.generated.is_match("ui/__snapshots__/x.snap"));
assert!(c.generated.is_match("migrations/0001_init.sql"));
assert!(!c.generated.is_match("src/main.rs"));
assert!(c.not_generated.is_match("important.lock"));
assert_eq!(c.attributes.len(), 2);
}
#[test]
fn malformed_config_is_a_hard_error() {
assert!(Config::parse("classify = 5", "test").is_err());
assert!(Config::parse("[classify]\nnope = true", "test").is_err());
}
#[test]
fn reserved_sections_are_accepted() {
Config::parse("[ordering]\nfuture = 1\n[stack]\nns = \"y\"", "test").unwrap();
}
#[test]
fn grouping_section_parses() {
let c = Config::parse(
"[grouping]\ncommand = [\"my-llm\", \"--flag\"]\ntimeout_secs = 60",
"test",
)
.unwrap();
assert_eq!(
c.grouping.command.as_deref(),
Some(&["my-llm".to_string(), "--flag".to_string()][..])
);
assert_eq!(c.grouping.timeout_secs, Some(60));
assert!(Config::parse("[grouping]\nmodel = \"x\"", "test").is_err());
}
}