use std::path::{Path, PathBuf};
use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::Deserialize;
use crate::EngineError;
pub const CONFIG_FILE_NAME: &str = ".differential.toml";
pub const USER_CONFIG_DIR: &str = "differential";
pub const USER_CONFIG_FILE_NAME: &str = "config.toml";
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
#[serde(default)]
classify: RawClassify,
#[serde(default)]
grouping: Option<toml::Table>,
#[serde(default, rename = "ordering")]
_ordering: serde::de::IgnoredAny,
#[serde(default, rename = "stack")]
_stack: serde::de::IgnoredAny,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawUserConfig {
#[serde(default)]
grouping: GroupingConfig,
#[serde(default)]
review: ReviewConfig,
}
#[derive(Debug, Default)]
pub struct UserConfig {
pub grouping: GroupingConfig,
pub review: ReviewConfig,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Agent {
#[default]
ClaudeCode,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupingConfig {
#[serde(default)]
pub agent: Option<Agent>,
#[serde(default)]
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeName {
#[default]
Dark,
OneDark,
OneLight,
GruvboxDark,
GruvboxLight,
SolarizedDark,
SolarizedLight,
CatppuccinMocha,
CatppuccinLatte,
Dracula,
Monokai,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewConfig {
#[serde(default = "default_context")]
pub context: usize,
#[serde(default = "default_context_step")]
pub context_step: usize,
#[serde(default)]
pub diff: DiffLayout,
#[serde(default)]
pub theme: ThemeName,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DiffLayout {
#[default]
Split,
Unified,
}
impl DiffLayout {
pub fn is_split(self) -> bool {
matches!(self, DiffLayout::Split)
}
}
const fn default_context() -> usize {
3
}
const fn default_context_step() -> usize {
10
}
impl Default for ReviewConfig {
fn default() -> Self {
ReviewConfig {
context: default_context(),
context_step: default_context_step(),
diff: DiffLayout::default(),
theme: ThemeName::default(),
}
}
}
#[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,
pub review: ReviewConfig,
}
pub const DEFAULT_ATTRIBUTES: &[&str] = &["linguist-generated", "gitlab-generated"];
fn default_attributes() -> Vec<String> {
DEFAULT_ATTRIBUTES.iter().map(|s| s.to_string()).collect()
}
impl Default for Config {
fn default() -> Self {
Config {
generated: GlobSet::empty(),
not_generated: GlobSet::empty(),
attributes: default_attributes(),
grouping: GroupingConfig::default(),
review: ReviewConfig::default(),
}
}
}
pub fn user_config_path<S: crate::ports::ConfigSource>(src: &S) -> Option<PathBuf> {
Some(
src.user_config_dir()?
.join(USER_CONFIG_DIR)
.join(USER_CONFIG_FILE_NAME),
)
}
impl Config {
pub fn load<S: crate::ports::ConfigSource>(
src: &S,
repo_root: &Path,
repo_override: Option<&Path>,
user_override: Option<&Path>,
) -> Result<Config, EngineError> {
let repo_default = Some(repo_root.join(CONFIG_FILE_NAME));
let mut config = match resolve(src, repo_override, repo_default)? {
Some((text, origin)) => Self::parse(&text, &origin)?,
None => Config::default(),
};
let user_default = user_config_path(src);
if let Some((text, origin)) = resolve(src, user_override, user_default)? {
let user = Self::parse_user(&text, &origin)?;
config.grouping = user.grouping;
config.review = user.review;
}
Ok(config)
}
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(),
})?;
if raw.grouping.is_some() {
return Err(EngineError::Config {
path: origin.to_string(),
msg: "[grouping] moved to the user config \
(~/.config/differential/config.toml): the agent command is a \
per-user choice, not a repo setting"
.to_string(),
});
}
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(default_attributes),
grouping: GroupingConfig::default(),
review: ReviewConfig::default(),
})
}
pub fn parse_user(text: &str, origin: &str) -> Result<UserConfig, EngineError> {
let raw: RawUserConfig = toml::from_str(text).map_err(|e| EngineError::Config {
path: origin.to_string(),
msg: e.to_string(),
})?;
Ok(UserConfig {
grouping: raw.grouping,
review: raw.review,
})
}
}
fn resolve<S: crate::ports::ConfigSource>(
src: &S,
explicit: Option<&Path>,
default: Option<PathBuf>,
) -> Result<Option<(String, String)>, EngineError> {
match explicit {
Some(p) => Ok(Some((src.read_required(p)?, p.display().to_string()))),
None => {
let Some(p) = default else {
return Ok(None);
};
Ok(src.read(&p)?.map(|text| (text, p.display().to_string())))
}
}
}
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 {
const SRC: crate::store::OsConfigSource = crate::store::OsConfigSource;
use super::*;
#[test]
fn defaults_when_empty() {
let c = Config::parse("", "test").unwrap();
assert_eq!(c.attributes, ["linguist-generated", "gitlab-generated"]);
assert_eq!(c.attributes, DEFAULT_ATTRIBUTES);
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, ["linguist-generated", "custom-generated"]);
let only_own =
Config::parse("[classify]\nattributes = [\"custom-generated\"]", "test").unwrap();
assert_eq!(only_own.attributes, ["custom-generated"]);
}
#[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_in_repo_config_errors_with_migration_hint() {
let err = Config::parse("[grouping]\nagent = \"claude-code\"", "test").unwrap_err();
assert!(err.to_string().contains("user config"), "{err}");
}
#[test]
fn the_diff_layout_defaults_to_split_and_accepts_either_name() {
let u = Config::parse_user("[review]\ncontext = 3", "test").unwrap();
assert_eq!(u.review.diff, DiffLayout::Split);
assert!(u.review.diff.is_split());
let u = Config::parse_user("[review]\ndiff = \"unified\"", "test").unwrap();
assert_eq!(u.review.diff, DiffLayout::Unified);
assert!(!u.review.diff.is_split());
assert_eq!(u.review.context, 3, "setting one key must not zero another");
let u = Config::parse_user("[review]\ndiff = \"split\"", "test").unwrap();
assert_eq!(u.review.diff, DiffLayout::Split);
assert!(Config::parse_user("[review]\ndiff = \"side\"", "test").is_err());
}
#[test]
fn user_config_parses_grouping_and_review() {
let u = Config::parse_user(
"[grouping]\nagent = \"claude-code\"\ntimeout_secs = 60",
"test",
)
.unwrap();
assert_eq!(u.grouping.agent, Some(Agent::ClaudeCode));
assert_eq!(u.grouping.timeout_secs, Some(60));
assert_eq!(u.review.context, 3);
assert_eq!(u.review.context_step, 10);
let u = Config::parse_user("[review]\ncontext_step = 25", "test").unwrap();
assert_eq!(u.review.context_step, 25);
assert_eq!(u.review.context, 3, "one key set must not zero the other");
assert!(Config::parse_user("[grouping]\nmodel = \"x\"", "test").is_err());
let err = Config::parse_user("[grouping]\nagent = \"gpt\"", "test").unwrap_err();
assert!(err.to_string().contains("claude-code"), "{err}");
assert!(Config::parse_user("[grouping]\nagent = [\"my-llm\"]", "test").is_err());
assert!(Config::parse_user("[review]\nlines = 5", "test").is_err());
assert!(Config::parse_user("[classify]\ngenerated = []", "test").is_err());
}
#[test]
fn user_config_parses_the_theme_and_names_the_valid_ones() {
let u = Config::parse_user("[review]\ntheme = \"gruvbox-light\"", "test").unwrap();
assert_eq!(u.review.theme, ThemeName::GruvboxLight);
let u = Config::parse_user("[review]\ncontext = 8", "test").unwrap();
assert_eq!(u.review.theme, ThemeName::Dark);
assert_eq!(u.review.context, 8);
let err = Config::parse_user("[review]\ntheme = \"nosferatu\"", "test").unwrap_err();
let msg = err.to_string();
for name in [
"dark",
"light",
"gruvbox-dark",
"solarized-light",
"monokai",
] {
assert!(msg.contains(name), "{name} missing from: {msg}");
}
}
#[test]
fn a_theme_in_the_repo_config_is_rejected() {
let err = Config::parse("[review]\ntheme = \"one-light\"", "test").unwrap_err();
assert!(err.to_string().contains("review"), "{err}");
}
#[test]
fn load_composes_repo_and_user_files() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_file = tmp.path().join("repo.toml");
let user_file = tmp.path().join("user.toml");
std::fs::write(&repo_file, "[classify]\ngenerated = [\"gen/**\"]").unwrap();
std::fs::write(
&user_file,
"[grouping]\nagent = \"claude-code\"\n[review]\ncontext = 8",
)
.unwrap();
let c = Config::load(
&crate::store::OsConfigSource,
tmp.path(),
Some(&repo_file),
Some(&user_file),
)
.unwrap();
assert!(c.generated.is_match("gen/x"));
assert_eq!(c.grouping.agent, Some(Agent::ClaudeCode));
assert_eq!(c.review.context, 8);
assert!(
Config::load(&SRC, tmp.path(), Some(Path::new("/nope")), Some(&user_file)).is_err()
);
assert!(Config::load(&SRC, tmp.path(), None, Some(&user_file)).is_ok());
}
}