use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::{Deserialize, Serialize};
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,
#[serde(default)]
keys: KeysConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct UserConfig {
pub grouping: GroupingConfig,
pub review: ReviewConfig,
#[serde(skip_serializing_if = "KeysConfig::is_empty")]
pub keys: KeysConfig,
}
#[derive(
Debug,
Clone,
Copy,
Default,
PartialEq,
Eq,
Deserialize,
Serialize,
strum::IntoStaticStr,
strum::VariantArray,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum Agent {
#[default]
ClaudeCode,
Codex,
Droid,
Copilot,
Pi,
}
impl Agent {
pub const ALL: &'static [Agent] = <Agent as strum::VariantArray>::VARIANTS;
pub fn key(self) -> &'static str {
self.into()
}
pub fn proven(self) -> bool {
match self {
Agent::ClaudeCode | Agent::Codex | Agent::Pi => true,
Agent::Droid | Agent::Copilot => false,
}
}
pub fn read_only(self) -> ReadOnly {
match self {
Agent::ClaudeCode | Agent::Copilot => ReadOnly::ToolAllowlist,
Agent::Codex => ReadOnly::OsSandbox,
Agent::Droid => ReadOnly::AgentDefault,
Agent::Pi => ReadOnly::NotEnforced,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadOnly {
ToolAllowlist,
OsSandbox,
AgentDefault,
NotEnforced,
}
impl ReadOnly {
pub fn is_enforced(self) -> bool {
!matches!(self, ReadOnly::NotEnforced)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct GroupingConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<Agent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
}
pub const DEFAULT_TIMEOUT_SECS: u64 = 1200;
#[derive(
Debug,
Clone,
Copy,
Default,
PartialEq,
Eq,
Deserialize,
Serialize,
strum::IntoStaticStr,
strum::VariantArray,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum ThemeName {
#[default]
Dark,
OneDark,
OneLight,
GruvboxDark,
GruvboxLight,
SolarizedDark,
SolarizedLight,
CatppuccinMocha,
CatppuccinLatte,
Dracula,
Monokai,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[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,
#[serde(default)]
pub editor: Option<String>,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Default,
Deserialize,
Serialize,
strum::IntoStaticStr,
strum::VariantArray,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum DiffLayout {
#[default]
Split,
Unified,
}
impl DiffLayout {
pub const ALL: &'static [DiffLayout] = <DiffLayout as strum::VariantArray>::VARIANTS;
pub fn is_split(self) -> bool {
matches!(self, DiffLayout::Split)
}
pub fn key(self) -> &'static str {
self.into()
}
}
impl ThemeName {
pub const ALL: &'static [ThemeName] = <ThemeName as strum::VariantArray>::VARIANTS;
pub fn key(self) -> &'static str {
self.into()
}
}
pub const EDITOR_FILE: &str = "{file}";
pub const EDITOR_LINE: &str = "{line}";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditorCommand {
argv: Vec<String>,
carries_line: bool,
carries_file: bool,
}
impl EditorCommand {
pub fn parse(text: &str, origin: &str) -> Result<EditorCommand, EngineError> {
let fail = |msg: String| EngineError::Config {
path: origin.to_string(),
msg,
};
let argv = shlex::split(text).ok_or_else(|| {
fail(format!(
"editor command does not split into words \
(an unbalanced quote?): {text:?}"
))
})?;
if argv.is_empty() {
return Err(fail("editor command is empty".to_string()));
}
if argv[0].contains(EDITOR_FILE) || argv[0].contains(EDITOR_LINE) {
return Err(fail(format!(
"the first word is the program to run, and it may not be a \
placeholder: {:?}",
argv[0]
)));
}
Ok(EditorCommand {
carries_line: argv.iter().any(|w| w.contains(EDITOR_LINE)),
carries_file: argv.iter().any(|w| w.contains(EDITOR_FILE)),
argv,
})
}
pub fn argv(&self, file: &Path, line: u32) -> Vec<String> {
let path = file.to_string_lossy();
let line = line.to_string();
let mut argv: Vec<String> = self
.argv
.iter()
.map(|w| w.replace(EDITOR_LINE, &line).replace(EDITOR_FILE, &path))
.collect();
if !self.carries_file {
argv.push(path.into_owned());
}
argv
}
pub fn carries_line(&self) -> bool {
self.carries_line
}
pub fn program(&self) -> &str {
&self.argv[0]
}
}
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(),
editor: None,
}
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Deserialize,
Serialize,
strum::IntoStaticStr,
strum::VariantArray,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum Action {
ToggleFocus,
Open,
Close,
Down,
Up,
NextGroup,
PrevGroup,
HalfPageDown,
HalfPageUp,
Top,
Bottom,
NextHunk,
PrevHunk,
ToggleSplit,
ToggleWrap,
ShiftRight,
ShiftLeft,
ShiftReset,
GrowDiff,
ShrinkDiff,
Fold,
Files,
ExternalEditor,
Findings,
Search,
ToggleReviewed,
Select,
Comment,
Delete,
ClearNotes,
Copy,
Reply,
Resolve,
Refetch,
Publish,
Back,
}
impl Action {
pub const ALL: &'static [Action] = <Action as strum::VariantArray>::VARIANTS;
pub fn key(self) -> &'static str {
self.into()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(transparent)]
pub struct KeysConfig(pub BTreeMap<Action, Vec<String>>);
impl KeysConfig {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn render_list(keys: &[String]) -> String {
let list = keys.iter().cloned().map(toml::Value::String).collect();
toml::Value::Array(list).to_string()
}
pub fn parse_list(text: &str) -> Result<Vec<String>, String> {
#[derive(Deserialize)]
struct One {
v: Vec<String>,
}
toml::from_str::<One>(&format!("v = {text}"))
.map(|one| one.v)
.map_err(|e| e.message().to_string())
}
}
#[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 keys: KeysConfig,
}
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(),
keys: KeysConfig::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 = Self::load_user(src, user_override)?;
config.grouping = user.grouping;
config.review = user.review;
config.keys = user.keys;
Ok(config)
}
pub fn load_user<S: crate::ports::ConfigSource>(
src: &S,
user_override: Option<&Path>,
) -> Result<UserConfig, EngineError> {
match resolve(src, user_override, user_config_path(src))? {
Some((text, origin)) => Self::parse_user(&text, &origin),
None => Ok(UserConfig::default()),
}
}
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(),
keys: KeysConfig::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,
keys: raw.keys,
})
}
}
impl Config {
pub fn render_user(user: &UserConfig) -> String {
toml::to_string_pretty(user).expect("the user config is plain data and always serialises")
}
pub fn save_user<S: crate::ports::ConfigSource>(
src: &S,
path: &Path,
user: &UserConfig,
) -> Result<(), EngineError> {
let text = Self::render_user(user);
let origin = path.display().to_string();
let back = Self::parse_user(&text, &origin)?;
if &back != user {
return Err(EngineError::Config {
path: origin,
msg: "the config did not read back as it was written".into(),
});
}
src.save(path, &text)
}
}
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();
let text = err.to_string();
for &agent in Agent::ALL {
assert!(
text.contains(agent.key()),
"the error must name {}: {text}",
agent.key()
);
}
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 every_agent_name_round_trips() {
for &agent in Agent::ALL {
let toml = format!("[grouping]\nagent = \"{}\"", agent.key());
let u = Config::parse_user(&toml, "test")
.unwrap_or_else(|e| panic!("{} must parse: {e}", agent.key()));
assert_eq!(u.grouping.agent, Some(agent), "{}", agent.key());
}
}
#[test]
fn which_agents_have_actually_been_run_is_pinned() {
let proven: Vec<&str> = Agent::ALL
.iter()
.filter(|a| a.proven())
.map(|a| a.key())
.collect();
assert_eq!(proven, vec!["claude-code", "codex", "pi"]);
let unproven: Vec<&str> = Agent::ALL
.iter()
.filter(|a| !a.proven())
.map(|a| a.key())
.collect();
assert_eq!(unproven, vec!["droid", "copilot"]);
assert!(Agent::default().proven(), "the default must be proven");
}
#[test]
fn exactly_one_agent_does_not_enforce_read_only() {
let unenforced: Vec<&str> = Agent::ALL
.iter()
.filter(|a| !a.read_only().is_enforced())
.map(|a| a.key())
.collect();
assert_eq!(unenforced, vec!["pi"], "{unenforced:?}");
assert!(
Agent::default().read_only().is_enforced(),
"the default must be enforced"
);
}
#[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 the_editor_command_puts_the_path_and_the_line_where_the_reader_said() {
let u = Config::parse_user("[review]\neditor = \"nvim +{line} {file}\"", "test").unwrap();
let cmd = EditorCommand::parse(u.review.editor.as_deref().unwrap(), "test").unwrap();
assert_eq!(
cmd.argv(Path::new("/w/src/x.rs"), 42),
["nvim", "+42", "/w/src/x.rs"]
);
assert!(cmd.carries_line());
assert_eq!(cmd.program(), "nvim");
let cmd = EditorCommand::parse("code -g {file}:{line}", "test").unwrap();
assert_eq!(
cmd.argv(Path::new("/w/src/x.rs"), 7),
["code", "-g", "/w/src/x.rs:7"]
);
let cmd =
EditorCommand::parse("\"/Applications/My Editor\" --at {line} {file}", "test").unwrap();
assert_eq!(
cmd.argv(Path::new("/w/x.rs"), 3),
["/Applications/My Editor", "--at", "3", "/w/x.rs"]
);
}
#[test]
fn a_command_naming_no_placeholder_still_gets_the_path() {
let cmd = EditorCommand::parse("vim", "test").unwrap();
assert_eq!(cmd.argv(Path::new("/w/x.rs"), 42), ["vim", "/w/x.rs"]);
assert!(!cmd.carries_line());
let cmd = EditorCommand::parse("emacsclient -nw", "test").unwrap();
assert_eq!(
cmd.argv(Path::new("/w/x.rs"), 42),
["emacsclient", "-nw", "/w/x.rs"]
);
assert!(!cmd.carries_line());
let cmd = EditorCommand::parse("vim +{line}", "test").unwrap();
assert_eq!(
cmd.argv(Path::new("/w/x.rs"), 42),
["vim", "+42", "/w/x.rs"]
);
assert!(cmd.carries_line());
}
#[test]
fn a_path_holding_a_placeholder_is_not_read_as_one() {
let cmd = EditorCommand::parse("nvim +{line} {file}", "test").unwrap();
assert_eq!(
cmd.argv(Path::new("/w/{line}/x.rs"), 9),
["nvim", "+9", "/w/{line}/x.rs"]
);
assert_eq!(
cmd.argv(Path::new("/w/{file}/x.rs"), 9),
["nvim", "+9", "/w/{file}/x.rs"]
);
}
#[test]
fn an_unrunnable_editor_command_is_an_error() {
let err = EditorCommand::parse("", "test").unwrap_err();
assert!(err.to_string().contains("empty"), "{err}");
assert!(EditorCommand::parse(" ", "test").is_err());
let err = EditorCommand::parse("vim \"unclosed", "test").unwrap_err();
assert!(err.to_string().contains("quote"), "{err}");
}
#[test]
fn the_program_word_may_not_be_a_placeholder() {
for bad in ["{file}", "{file} {line}", "{line}", "pre{file}post vim"] {
let err = EditorCommand::parse(bad, "test").unwrap_err().to_string();
assert!(err.contains("first word"), "{bad:?} gave {err}");
}
assert!(EditorCommand::parse("vim +{line} {file}", "test").is_ok());
assert!(EditorCommand::parse("code -g {file}:{line}", "test").is_ok());
}
#[test]
fn the_editor_key_is_optional_and_independent() {
let u = Config::parse_user("[review]\ncontext = 8", "test").unwrap();
assert_eq!(u.review.editor, None);
let u = Config::parse_user("[review]\neditor = \"hx {file}:{line}\"", "test").unwrap();
assert_eq!(u.review.editor.as_deref(), Some("hx {file}:{line}"));
assert_eq!(u.review.context, 3);
assert_eq!(u.review.context_step, 10);
assert_eq!(u.review.theme, ThemeName::Dark);
assert_eq!(u.review.diff, DiffLayout::Split);
}
#[test]
fn an_editor_in_the_repo_config_is_rejected() {
assert!(Config::parse("[review]\neditor = \"vim\"", "test").is_err());
}
#[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\n[keys]\ntop = [\"Q\"]",
)
.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_eq!(c.keys.0[&Action::Top], ["Q"]);
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());
}
#[test]
fn keys_map_action_names_to_the_strings_as_written() {
let u =
Config::parse_user("[keys]\nnext-group = [\"ctrl-j\"]\npublish = []", "test").unwrap();
assert_eq!(u.keys.0[&Action::NextGroup], ["ctrl-j"]);
assert_eq!(u.keys.0[&Action::Publish], Vec::<String>::new());
assert!(
!u.keys.0.contains_key(&Action::Top),
"unnamed keeps defaults"
);
assert!(Config::parse_user("", "test").unwrap().keys.0.is_empty());
}
#[test]
fn an_unknown_action_is_an_error_naming_every_action() {
let err = Config::parse_user("[keys]\nexplode = [\"x\"]", "test").unwrap_err();
let text = err.to_string();
for &action in Action::ALL {
assert!(
text.contains(action.key()),
"must name {}: {text}",
action.key()
);
}
assert!(Config::parse_user("[keys]\ntop = \"g\"", "test").is_err());
}
#[test]
fn keys_are_the_users_and_not_the_repos() {
let err = Config::parse("[keys]\ntop = [\"g\"]", "test").unwrap_err();
assert!(err.to_string().contains("keys"), "{err}");
}
#[test]
fn every_action_name_round_trips() {
for &action in Action::ALL {
let text = format!("[keys]\n{} = []", action.key());
let u = Config::parse_user(&text, "test").unwrap();
assert!(
u.keys.0.contains_key(&action),
"{} did not parse",
action.key()
);
}
}
#[test]
fn a_rendered_user_config_reads_back_as_itself() {
let full = Config::parse_user(
"[grouping]\nagent = \"codex\"\ntimeout_secs = 60\n\
[review]\ntheme = \"gruvbox-light\"\ncontext = 8\ncontext_step = 4\ndiff = \"unified\"\n\
[keys]\nnext-group = [\"ctrl-j\"]\npublish = []",
"test",
)
.unwrap();
for user in [full, UserConfig::default()] {
let text = Config::render_user(&user);
assert_eq!(Config::parse_user(&text, "test").unwrap(), user, "{text}");
}
let text = Config::render_user(&UserConfig::default());
assert!(
!text.contains("[keys]") && !text.contains("agent"),
"{text}"
);
}
#[test]
fn save_user_writes_the_file_and_its_directory() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("differential").join("config.toml");
let mut user = UserConfig::default();
user.review.theme = ThemeName::Dracula;
Config::save_user(&SRC, &path, &user).unwrap();
let back = Config::load_user(&SRC, Some(&path)).unwrap();
assert_eq!(back, user);
}
#[test]
fn every_theme_and_layout_name_round_trips() {
for &theme in ThemeName::ALL {
match theme {
ThemeName::Dark
| ThemeName::OneDark
| ThemeName::OneLight
| ThemeName::GruvboxDark
| ThemeName::GruvboxLight
| ThemeName::SolarizedDark
| ThemeName::SolarizedLight
| ThemeName::CatppuccinMocha
| ThemeName::CatppuccinLatte
| ThemeName::Dracula
| ThemeName::Monokai => {}
}
let text = format!("[review]\ntheme = \"{}\"", theme.key());
assert_eq!(
Config::parse_user(&text, "test").unwrap().review.theme,
theme
);
}
for &diff in DiffLayout::ALL {
match diff {
DiffLayout::Split | DiffLayout::Unified => {}
}
let text = format!("[review]\ndiff = \"{}\"", diff.key());
assert_eq!(Config::parse_user(&text, "test").unwrap().review.diff, diff);
}
}
#[test]
fn a_key_list_round_trips_in_the_files_syntax() {
let keys = vec!["ctrl-j".to_string(), "d d".to_string(), "\"".to_string()];
let text = KeysConfig::render_list(&keys);
assert_eq!(KeysConfig::parse_list(&text).unwrap(), keys, "{text}");
assert_eq!(KeysConfig::parse_list("[]").unwrap(), Vec::<String>::new());
assert!(KeysConfig::parse_list("ctrl-j").is_err());
assert!(KeysConfig::parse_list("[1]").is_err());
}
}