use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::PawError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CustomCli {
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Preset {
pub branches: Vec<String>,
pub cli: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SpecsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
pub spec_type: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct LoggingConfig {
#[serde(default)]
pub enabled: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ApprovalLevel {
Manual,
#[default]
Auto,
FullAuto,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DashboardConfig {
#[serde(default)]
pub show_message_log: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SupervisorConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cli: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub test_command: Option<String>,
#[serde(default)]
pub agent_approval: ApprovalLevel,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_approve: Option<AutoApproveConfig>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ApprovalLevelPreset {
Off,
Conservative,
#[default]
Safe,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AutoApproveConfig {
#[serde(default = "AutoApproveConfig::default_enabled")]
pub enabled: bool,
#[serde(default)]
pub safe_commands: Vec<String>,
#[serde(default = "AutoApproveConfig::default_stall_threshold_seconds")]
pub stall_threshold_seconds: u64,
#[serde(default)]
pub approval_level: ApprovalLevelPreset,
}
impl Default for AutoApproveConfig {
fn default() -> Self {
Self {
enabled: Self::default_enabled(),
safe_commands: Vec::new(),
stall_threshold_seconds: Self::default_stall_threshold_seconds(),
approval_level: ApprovalLevelPreset::Safe,
}
}
}
impl AutoApproveConfig {
pub const MIN_STALL_THRESHOLD_SECONDS: u64 = 5;
fn default_enabled() -> bool {
true
}
fn default_stall_threshold_seconds() -> u64 {
30
}
#[must_use]
pub fn resolved(&self) -> Self {
let mut out = self.clone();
if out.approval_level == ApprovalLevelPreset::Off {
out.enabled = false;
}
if out.stall_threshold_seconds < Self::MIN_STALL_THRESHOLD_SECONDS {
eprintln!(
"warning: [supervisor.auto_approve] stall_threshold_seconds = {} clamped to {}s minimum",
out.stall_threshold_seconds,
Self::MIN_STALL_THRESHOLD_SECONDS
);
out.stall_threshold_seconds = Self::MIN_STALL_THRESHOLD_SECONDS;
}
out
}
#[must_use]
pub fn effective_whitelist(&self) -> Vec<String> {
let mut out: Vec<String> = crate::supervisor::auto_approve::default_safe_commands()
.iter()
.map(|s| (*s).to_string())
.collect();
for extra in &self.safe_commands {
if !out.iter().any(|e| e == extra) {
out.push(extra.clone());
}
}
if self.approval_level == ApprovalLevelPreset::Conservative {
out.retain(|cmd| !cmd.starts_with("git push") && !cmd.starts_with("curl"));
}
out
}
}
#[must_use]
pub fn approval_flags(cli: &str, level: &ApprovalLevel) -> &'static str {
match (cli, level) {
("claude", ApprovalLevel::FullAuto) => "--dangerously-skip-permissions",
("codex", ApprovalLevel::FullAuto) => "--approval-mode=full-auto",
("codex", ApprovalLevel::Auto) => "--approval-mode=auto-edit",
_ => "",
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BrokerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "BrokerConfig::default_port")]
pub port: u16,
#[serde(default = "BrokerConfig::default_bind")]
pub bind: String,
}
impl Default for BrokerConfig {
fn default() -> Self {
Self {
enabled: false,
port: 9119,
bind: "127.0.0.1".to_string(),
}
}
}
impl BrokerConfig {
pub fn url(&self) -> String {
format!("http://{}:{}", self.bind, self.port)
}
fn default_port() -> u16 {
9119
}
fn default_bind() -> String {
"127.0.0.1".to_string()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PawConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_cli: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_spec_cli: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mouse: Option<bool>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub clis: HashMap<String, CustomCli>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub presets: HashMap<String, Preset>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub specs: Option<SpecsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logging: Option<LoggingConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dashboard: Option<DashboardConfig>,
#[serde(default)]
pub broker: BrokerConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supervisor: Option<SupervisorConfig>,
}
impl PawConfig {
#[must_use]
pub fn merged_with(&self, overlay: &Self) -> Self {
let mut clis = self.clis.clone();
for (k, v) in &overlay.clis {
clis.insert(k.clone(), v.clone());
}
let mut presets = self.presets.clone();
for (k, v) in &overlay.presets {
presets.insert(k.clone(), v.clone());
}
Self {
default_cli: overlay
.default_cli
.clone()
.or_else(|| self.default_cli.clone()),
default_spec_cli: overlay
.default_spec_cli
.clone()
.or_else(|| self.default_spec_cli.clone()),
branch_prefix: overlay
.branch_prefix
.clone()
.or_else(|| self.branch_prefix.clone()),
mouse: overlay.mouse.or(self.mouse),
clis,
presets,
specs: overlay.specs.clone().or_else(|| self.specs.clone()),
logging: overlay.logging.clone().or_else(|| self.logging.clone()),
dashboard: overlay.dashboard.clone().or_else(|| self.dashboard.clone()),
broker: if overlay.broker == BrokerConfig::default() {
self.broker.clone()
} else {
overlay.broker.clone()
},
supervisor: overlay
.supervisor
.clone()
.or_else(|| self.supervisor.clone()),
}
}
pub fn get_preset(&self, name: &str) -> Option<&Preset> {
self.presets.get(name)
}
pub fn get_dashboard(&self) -> Option<&DashboardConfig> {
self.dashboard.as_ref()
}
}
pub fn global_config_path() -> Result<PathBuf, PawError> {
crate::dirs::config_dir()
.map(|d| d.join("git-paw").join("config.toml"))
.ok_or_else(|| PawError::ConfigError("could not determine config directory".into()))
}
pub fn repo_config_path(repo_root: &Path) -> PathBuf {
repo_root.join(".git-paw").join("config.toml")
}
fn load_config_file(path: &Path) -> Result<Option<PawConfig>, PawError> {
match fs::read_to_string(path) {
Ok(contents) => {
let config: PawConfig = toml::from_str(&contents)
.map_err(|e| PawError::ConfigError(format!("{}: {e}", path.display())))?;
Ok(Some(config))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(PawError::ConfigError(format!("{}: {e}", path.display()))),
}
}
pub fn load_repo_config(repo_root: &Path) -> Result<PawConfig, PawError> {
Ok(load_config_file(&repo_config_path(repo_root))?.unwrap_or_default())
}
pub fn load_config(repo_root: &Path) -> Result<PawConfig, PawError> {
let global_path = global_config_path()?;
load_config_from(&global_path, repo_root)
}
pub fn load_config_from(global_path: &Path, repo_root: &Path) -> Result<PawConfig, PawError> {
let global = load_config_file(global_path)?.unwrap_or_default();
let repo = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
Ok(global.merged_with(&repo))
}
pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
save_config_to(&repo_config_path(repo_root), config)
}
fn save_config_to(path: &Path, config: &PawConfig) -> Result<(), PawError> {
let dir = path
.parent()
.ok_or_else(|| PawError::ConfigError("invalid config path".into()))?;
fs::create_dir_all(dir)
.map_err(|e| PawError::ConfigError(format!("create config dir: {e}")))?;
let contents =
toml::to_string_pretty(config).map_err(|e| PawError::ConfigError(e.to_string()))?;
let tmp = path.with_extension("toml.tmp");
fs::write(&tmp, &contents)
.map_err(|e| PawError::ConfigError(format!("write temp config: {e}")))?;
fs::rename(&tmp, path).map_err(|e| PawError::ConfigError(format!("rename config: {e}")))?;
Ok(())
}
pub fn add_custom_cli(
name: &str,
command: &str,
display_name: Option<&str>,
) -> Result<(), PawError> {
add_custom_cli_to(&global_config_path()?, name, command, display_name)
}
pub fn add_custom_cli_to(
config_path: &Path,
name: &str,
command: &str,
display_name: Option<&str>,
) -> Result<(), PawError> {
let resolved_command = if Path::new(command).is_absolute() {
command.to_string()
} else {
which::which(command)
.map_err(|_| PawError::ConfigError(format!("command '{command}' not found on PATH")))?
.to_string_lossy()
.into_owned()
};
let mut config = load_config_file(config_path)?.unwrap_or_default();
config.clis.insert(
name.to_string(),
CustomCli {
command: resolved_command,
display_name: display_name.map(String::from),
},
);
save_config_to(config_path, &config)
}
pub fn generate_default_config() -> String {
r#"# git-paw configuration
# See https://github.com/bearicorn/git-paw for documentation.
# Pre-select a CLI in the interactive picker (user can still change).
# Omit to show the full picker with no default.
# default_cli = ""
# Enable tmux mouse mode for sessions (default: true).
# mouse = true
# Bypass the CLI picker entirely for --from-specs mode.
# Omit to prompt or use per-spec paw_cli fields.
# default_spec_cli = ""
# Prefix for spec-derived branch names (default: "spec/" ).
# branch_prefix = "spec/"
# Dashboard message log configuration.
# [dashboard]
# show_message_log = false
# Spec scanning configuration.
# [specs]
# dir = "specs"
#
# OpenSpec format (directory-based, default):
# type = "openspec"
#
# Markdown format (frontmatter-based):
# type = "markdown"
# Each .md file uses YAML frontmatter fields:
# paw_status — "pending" | "done" | "in-progress" (required)
# paw_branch — branch name suffix (optional, falls back to filename)
# paw_cli — CLI override for this spec (optional)
# Session logging configuration.
# [logging]
# enabled = false
# HTTP broker for agent coordination (requires --broker flag on start).
# [broker]
# enabled = true
# port = 9119
# bind = "127.0.0.1"
# Supervisor mode — git-paw acts as a coordinating layer in front of the
# agent CLI, enforcing approval policy and optionally running a test
# command after each agent completes.
# [supervisor]
# enabled = true
# cli = "claude"
# test_command = "just check"
# agent_approval = "auto" # one of: "manual", "auto", "full-auto"
# Custom CLI definitions.
# [clis.my-agent]
# command = "/usr/local/bin/my-agent"
# display_name = "My Agent"
# Named presets for quick launches.
# [presets.my-preset]
# branches = ["feat/api", "fix/db"]
# cli = ""
"#
.to_string()
}
pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
remove_custom_cli_from(&global_config_path()?, name)
}
pub fn remove_custom_cli_from(config_path: &Path, name: &str) -> Result<(), PawError> {
let mut config = load_config_file(config_path)?.unwrap_or_default();
if config.clis.remove(name).is_none() {
return Err(PawError::CliNotFound(name.to_string()));
}
save_config_to(config_path, &config)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_file(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
#[test]
fn parses_config_with_all_fields() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
r#"
default_cli = "claude"
mouse = false
default_spec_cli = "gemini"
branch_prefix = "spec/"
[clis.my-agent]
command = "/usr/local/bin/my-agent"
display_name = "My Agent"
[clis.local-llm]
command = "ollama-code"
[presets.backend]
branches = ["feature/api", "fix/db"]
cli = "claude"
[specs]
dir = "my-specs"
type = "openspec"
[logging]
enabled = true
"#,
);
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(config.default_cli.as_deref(), Some("claude"));
assert_eq!(config.mouse, Some(false));
assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
assert_eq!(config.clis.len(), 2);
assert_eq!(
config.clis["my-agent"].display_name.as_deref(),
Some("My Agent")
);
assert_eq!(config.clis["local-llm"].command, "ollama-code");
assert_eq!(config.presets["backend"].cli, "claude");
assert_eq!(
config.presets["backend"].branches,
vec!["feature/api", "fix/db"]
);
let specs = config.specs.unwrap();
assert_eq!(specs.dir.as_deref(), Some("my-specs"));
assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
let logging = config.logging.unwrap();
assert!(logging.enabled);
}
#[test]
fn all_fields_are_optional() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "default_cli = \"gemini\"\n");
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(config.default_cli.as_deref(), Some("gemini"));
assert_eq!(config.mouse, None);
assert!(config.clis.is_empty());
assert!(config.presets.is_empty());
}
#[test]
fn returns_defaults_when_no_files_exist() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("nonexistent").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.default_cli, None);
assert_eq!(config.mouse, None);
assert!(config.clis.is_empty());
assert!(config.presets.is_empty());
}
#[test]
fn reports_error_for_invalid_toml() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("bad.toml");
write_file(&path, "this is not [valid toml");
let err = load_config_file(&path).unwrap_err();
assert!(err.to_string().contains("bad.toml"));
}
#[test]
fn repo_config_overrides_global_scalars() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "default_cli = \"claude\"\nmouse = true\n");
write_file(
&repo_config_path(&repo_root),
"default_cli = \"gemini\"\n", );
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.default_cli.as_deref(), Some("gemini")); assert_eq!(config.mouse, Some(true)); }
#[test]
fn repo_config_merges_cli_maps() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "[clis.agent-a]\ncommand = \"/bin/a\"\n");
write_file(
&repo_config_path(&repo_root),
"[clis.agent-b]\ncommand = \"/bin/b\"\n",
);
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.clis.len(), 2);
assert!(config.clis.contains_key("agent-a"));
assert!(config.clis.contains_key("agent-b"));
}
#[test]
fn repo_cli_overrides_global_cli_with_same_name() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "[clis.my-agent]\ncommand = \"/old/path\"\n");
write_file(
&repo_config_path(&repo_root),
"[clis.my-agent]\ncommand = \"/new/path\"\ndisplay_name = \"Overridden\"\n",
);
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.clis["my-agent"].command, "/new/path");
assert_eq!(
config.clis["my-agent"].display_name.as_deref(),
Some("Overridden")
);
}
#[test]
fn load_config_from_reads_global_file_when_no_repo() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "default_cli = \"claude\"\nmouse = false\n");
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.default_cli.as_deref(), Some("claude"));
assert_eq!(config.mouse, Some(false));
}
#[test]
fn load_config_from_reads_repo_file_when_no_global() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("nonexistent").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&repo_config_path(&repo_root), "default_cli = \"codex\"\n");
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.default_cli.as_deref(), Some("codex"));
}
#[test]
fn preset_accessible_by_name() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(
&repo_config_path(&repo_root),
"[presets.backend]\nbranches = [\"feat/api\", \"fix/db\"]\ncli = \"claude\"\n",
);
let config = load_config_from(&global_path, &repo_root).unwrap();
let preset = config.get_preset("backend").unwrap();
assert_eq!(preset.cli, "claude");
assert_eq!(preset.branches, vec!["feat/api", "fix/db"]);
}
#[test]
fn preset_returns_none_when_not_in_config() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("config.toml");
write_file(&global_path, "default_cli = \"claude\"\n");
let config = load_config_file(&global_path).unwrap().unwrap();
assert!(config.get_preset("nonexistent").is_none());
}
#[test]
fn add_cli_writes_to_config_file() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("git-paw").join("config.toml");
add_custom_cli_to(
&config_path,
"my-agent",
"/usr/local/bin/my-agent",
Some("My Agent"),
)
.unwrap();
let config = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(config.clis.len(), 1);
assert_eq!(config.clis["my-agent"].command, "/usr/local/bin/my-agent");
assert_eq!(
config.clis["my-agent"].display_name.as_deref(),
Some("My Agent")
);
}
#[test]
fn add_cli_preserves_existing_entries() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("git-paw").join("config.toml");
add_custom_cli_to(&config_path, "first", "/bin/first", None).unwrap();
add_custom_cli_to(&config_path, "second", "/bin/second", None).unwrap();
let config = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(config.clis.len(), 2);
assert!(config.clis.contains_key("first"));
assert!(config.clis.contains_key("second"));
}
#[test]
fn add_cli_errors_when_command_not_on_path() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let err = add_custom_cli_to(&config_path, "bad", "surely-nonexistent-binary-xyz", None)
.unwrap_err();
assert!(err.to_string().contains("not found on PATH"));
}
#[test]
fn remove_cli_deletes_entry_from_config_file() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("git-paw").join("config.toml");
add_custom_cli_to(&config_path, "keep-me", "/bin/keep", None).unwrap();
add_custom_cli_to(&config_path, "remove-me", "/bin/remove", None).unwrap();
remove_custom_cli_from(&config_path, "remove-me").unwrap();
let config = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(config.clis.len(), 1);
assert!(config.clis.contains_key("keep-me"));
assert!(!config.clis.contains_key("remove-me"));
}
#[test]
fn remove_nonexistent_cli_returns_cli_not_found_error() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
write_file(&config_path, "");
let err = remove_custom_cli_from(&config_path, "nonexistent").unwrap_err();
match err {
PawError::CliNotFound(name) => assert_eq!(name, "nonexistent"),
other => panic!("expected CliNotFound, got: {other}"),
}
}
#[test]
fn remove_cli_from_empty_config_returns_error() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let err = remove_custom_cli_from(&config_path, "ghost").unwrap_err();
match err {
PawError::CliNotFound(name) => assert_eq!(name, "ghost"),
other => panic!("expected CliNotFound, got: {other}"),
}
}
#[test]
fn parses_default_spec_cli_when_present() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "default_spec_cli = \"claude\"\n");
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
}
#[test]
fn default_spec_cli_defaults_to_none() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "default_cli = \"claude\"\n");
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(config.default_spec_cli, None);
}
#[test]
fn repo_overrides_global_default_spec_cli() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "default_spec_cli = \"claude\"\n");
write_file(
&repo_config_path(&repo_root),
"default_spec_cli = \"gemini\"\n",
);
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
}
#[test]
fn global_default_spec_cli_preserved_when_repo_absent() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "default_spec_cli = \"claude\"\n");
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
}
#[test]
fn config_survives_save_and_load() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let original = PawConfig {
default_cli: Some("claude".into()),
default_spec_cli: None,
branch_prefix: None,
mouse: Some(true),
clis: HashMap::from([(
"test".into(),
CustomCli {
command: "/bin/test".into(),
display_name: Some("Test CLI".into()),
},
)]),
presets: HashMap::from([(
"dev".into(),
Preset {
branches: vec!["main".into()],
cli: "claude".into(),
},
)]),
specs: None,
logging: None,
dashboard: None,
broker: BrokerConfig::default(),
supervisor: None,
};
save_config_to(&config_path, &original).unwrap();
let loaded = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(original, loaded);
}
#[test]
fn parses_specs_section_with_populated_fields() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[specs]\ndir = \"my-specs\"\ntype = \"openspec\"\n");
let config = load_config_file(&path).unwrap().unwrap();
let specs = config.specs.unwrap();
assert_eq!(specs.dir.as_deref(), Some("my-specs"));
assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
}
#[test]
fn parses_logging_section_with_enabled() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[logging]\nenabled = true\n");
let config = load_config_file(&path).unwrap().unwrap();
let logging = config.logging.unwrap();
assert!(logging.enabled);
}
#[test]
fn round_trip_with_specs_and_logging() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let original = PawConfig {
specs: Some(SpecsConfig {
dir: Some("specs".into()),
spec_type: Some("openspec".into()),
}),
logging: Some(LoggingConfig { enabled: true }),
..Default::default()
};
save_config_to(&config_path, &original).unwrap();
let loaded = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(original, loaded);
assert_eq!(loaded.specs.unwrap().dir.as_deref(), Some("specs"));
assert!(loaded.logging.unwrap().enabled);
}
#[test]
fn generated_default_config_is_valid_toml() {
let raw = generate_default_config();
let stripped: String = raw
.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.collect::<Vec<&str>>()
.join("\n");
let parsed: Result<PawConfig, _> = toml::from_str(&stripped);
assert!(
parsed.is_ok(),
"generated config with comments stripped should be valid TOML, got: {:?}",
parsed.unwrap_err()
);
}
#[test]
fn branch_prefix_repo_overrides_global() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "branch_prefix = \"feat/\"\n");
write_file(&repo_config_path(&repo_root), "branch_prefix = \"spec/\"\n");
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
}
#[test]
fn generated_default_config_contains_commented_examples() {
let output = generate_default_config();
assert!(
output.contains("default_spec_cli"),
"should contain default_spec_cli"
);
assert!(
output.contains("branch_prefix"),
"should contain branch_prefix"
);
assert!(output.contains("[specs]"), "should contain [specs]");
assert!(output.contains("[logging]"), "should contain [logging]");
assert!(output.contains("[broker]"), "should contain [broker]");
}
#[test]
fn broker_config_defaults() {
let config = BrokerConfig::default();
assert!(!config.enabled);
assert_eq!(config.port, 9119);
assert_eq!(config.bind, "127.0.0.1");
}
#[test]
fn broker_config_url() {
let config = BrokerConfig::default();
assert_eq!(config.url(), "http://127.0.0.1:9119");
let custom = BrokerConfig {
enabled: true,
port: 8080,
bind: "0.0.0.0".to_string(),
};
assert_eq!(custom.url(), "http://0.0.0.0:8080");
}
#[test]
fn empty_config_gets_broker_defaults() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "");
let config = load_config_file(&path).unwrap().unwrap();
assert!(!config.broker.enabled);
assert_eq!(config.broker.port, 9119);
assert_eq!(config.broker.bind, "127.0.0.1");
}
#[test]
fn parses_full_broker_section() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[broker]\nenabled = true\nport = 8080\nbind = \"0.0.0.0\"\n",
);
let config = load_config_file(&path).unwrap().unwrap();
assert!(config.broker.enabled);
assert_eq!(config.broker.port, 8080);
assert_eq!(config.broker.bind, "0.0.0.0");
}
#[test]
fn parses_partial_broker_section() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[broker]\nenabled = true\n");
let config = load_config_file(&path).unwrap().unwrap();
assert!(config.broker.enabled);
assert_eq!(config.broker.port, 9119);
assert_eq!(config.broker.bind, "127.0.0.1");
}
#[test]
fn supervisor_is_none_when_section_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "default_cli = \"claude\"\n");
let config = load_config_file(&path).unwrap().unwrap();
assert!(config.supervisor.is_none());
}
#[test]
fn parses_full_supervisor_section() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\n\
enabled = true\n\
cli = \"claude\"\n\
test_command = \"just check\"\n\
agent_approval = \"full-auto\"\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert!(supervisor.enabled);
assert_eq!(supervisor.cli.as_deref(), Some("claude"));
assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
}
#[test]
fn parses_partial_supervisor_section() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[supervisor]\nenabled = true\n");
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert!(supervisor.enabled);
assert_eq!(supervisor.cli, None);
assert_eq!(supervisor.test_command, None);
assert_eq!(supervisor.agent_approval, ApprovalLevel::Auto);
}
#[test]
fn rejects_invalid_approval_level() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[supervisor]\nagent_approval = \"yolo\"\n");
let err = load_config_file(&path).unwrap_err();
assert!(
err.to_string().contains("yolo"),
"error should mention invalid value, got: {err}"
);
}
#[test]
fn supervisor_round_trips_through_save_and_load() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let original = PawConfig {
supervisor: Some(SupervisorConfig {
enabled: true,
cli: Some("claude".into()),
test_command: Some("just check".into()),
agent_approval: ApprovalLevel::FullAuto,
auto_approve: None,
}),
..Default::default()
};
save_config_to(&config_path, &original).unwrap();
let loaded = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(loaded.supervisor, original.supervisor);
}
#[test]
fn existing_v030_config_loads_without_supervisor() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"default_cli = \"claude\"\n\
mouse = true\n\
[broker]\n\
enabled = true\n\
[logging]\n\
enabled = false\n",
);
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(config.default_cli.as_deref(), Some("claude"));
assert!(config.broker.enabled);
assert!(config.supervisor.is_none());
}
#[test]
fn generated_default_config_contains_commented_supervisor_section() {
let output = generate_default_config();
assert!(output.contains("[supervisor]"));
assert!(output.contains("enabled"));
assert!(output.contains("test_command"));
assert!(output.contains("agent_approval"));
}
#[test]
fn dashboard_config_defaults_to_disabled() {
let config = DashboardConfig::default();
assert!(!config.show_message_log);
}
#[test]
fn parses_dashboard_section_with_show_message_log() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[dashboard]\nshow_message_log = true\n");
let config = load_config_file(&path).unwrap().unwrap();
let dashboard = config.dashboard.unwrap();
assert!(dashboard.show_message_log);
}
#[test]
fn dashboard_is_none_when_section_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "default_cli = \"claude\"\n");
let config = load_config_file(&path).unwrap().unwrap();
assert!(config.dashboard.is_none());
}
#[test]
fn dashboard_merge_repo_wins() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "[dashboard]\nshow_message_log = false\n");
write_file(
&repo_config_path(&repo_root),
"[dashboard]\nshow_message_log = true\n",
);
let config = load_config_from(&global_path, &repo_root).unwrap();
let dashboard = config.dashboard.unwrap();
assert!(dashboard.show_message_log);
}
#[test]
fn dashboard_round_trip_through_save_and_load() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let original = PawConfig {
dashboard: Some(DashboardConfig {
show_message_log: true,
}),
..Default::default()
};
save_config_to(&config_path, &original).unwrap();
let loaded = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(loaded.dashboard, original.dashboard);
assert!(loaded.dashboard.unwrap().show_message_log);
}
#[test]
fn get_dashboard_returns_none_when_not_configured() {
let config = PawConfig::default();
assert!(config.get_dashboard().is_none());
}
#[test]
fn get_dashboard_returns_config_when_present() {
let config = PawConfig {
dashboard: Some(DashboardConfig {
show_message_log: true,
}),
..Default::default()
};
let dashboard = config.get_dashboard().unwrap();
assert!(dashboard.show_message_log);
}
#[test]
fn approval_flags_claude_full_auto() {
assert_eq!(
approval_flags("claude", &ApprovalLevel::FullAuto),
"--dangerously-skip-permissions"
);
}
#[test]
fn approval_flags_codex_auto() {
assert_eq!(
approval_flags("codex", &ApprovalLevel::Auto),
"--approval-mode=auto-edit"
);
}
#[test]
fn approval_flags_codex_full_auto() {
assert_eq!(
approval_flags("codex", &ApprovalLevel::FullAuto),
"--approval-mode=full-auto"
);
}
#[test]
fn approval_flags_unknown_cli_is_empty() {
assert_eq!(approval_flags("some-agent", &ApprovalLevel::FullAuto), "");
}
#[test]
fn approval_flags_manual_is_empty() {
assert_eq!(approval_flags("claude", &ApprovalLevel::Manual), "");
assert_eq!(approval_flags("codex", &ApprovalLevel::Manual), "");
}
#[test]
fn approval_flags_is_deterministic() {
let first = approval_flags("claude", &ApprovalLevel::FullAuto);
let second = approval_flags("claude", &ApprovalLevel::FullAuto);
assert_eq!(first, second);
}
#[test]
fn supervisor_merge_repo_wins() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(
&global_path,
"[supervisor]\nenabled = false\nagent_approval = \"manual\"\n",
);
write_file(
&repo_config_path(&repo_root),
"[supervisor]\nenabled = true\nagent_approval = \"full-auto\"\n",
);
let config = load_config_from(&global_path, &repo_root).unwrap();
let supervisor = config.supervisor.unwrap();
assert!(supervisor.enabled);
assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
}
#[test]
fn broker_config_round_trip() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let original = PawConfig {
broker: BrokerConfig {
enabled: true,
port: 9200,
bind: "127.0.0.1".to_string(),
},
..Default::default()
};
save_config_to(&config_path, &original).unwrap();
let loaded = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(loaded.broker.enabled, original.broker.enabled);
assert_eq!(loaded.broker.port, original.broker.port);
assert_eq!(loaded.broker.bind, original.broker.bind);
}
#[test]
fn auto_approve_defaults_match_spec() {
let cfg = AutoApproveConfig::default();
assert!(cfg.enabled, "enabled defaults to true");
assert!(
cfg.safe_commands.is_empty(),
"safe_commands defaults to empty"
);
assert_eq!(cfg.stall_threshold_seconds, 30);
assert_eq!(cfg.approval_level, ApprovalLevelPreset::Safe);
}
#[test]
fn auto_approve_section_absent_keeps_supervisor_simple() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[supervisor]\nenabled = true\n");
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert!(supervisor.auto_approve.is_none());
}
#[test]
fn auto_approve_section_parses_full_body() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\n\
enabled = true\n\
[supervisor.auto_approve]\n\
enabled = false\n\
safe_commands = [\"just smoke\"]\n\
stall_threshold_seconds = 60\n\
approval_level = \"conservative\"\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let aa = config.supervisor.unwrap().auto_approve.unwrap();
assert!(!aa.enabled);
assert_eq!(aa.safe_commands, vec!["just smoke".to_string()]);
assert_eq!(aa.stall_threshold_seconds, 60);
assert_eq!(aa.approval_level, ApprovalLevelPreset::Conservative);
}
#[test]
fn auto_approve_enabled_defaults_to_true_when_omitted() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\n[supervisor.auto_approve]\nstall_threshold_seconds = 30\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let aa = config.supervisor.unwrap().auto_approve.unwrap();
assert!(aa.enabled, "enabled should default to true");
}
#[test]
fn auto_approve_off_preset_forces_disabled() {
let cfg = AutoApproveConfig {
enabled: true,
approval_level: ApprovalLevelPreset::Off,
..AutoApproveConfig::default()
};
let resolved = cfg.resolved();
assert!(!resolved.enabled, "Off preset must force enabled = false");
}
#[test]
fn auto_approve_threshold_floor_clamps() {
let cfg = AutoApproveConfig {
stall_threshold_seconds: 0,
..AutoApproveConfig::default()
};
let resolved = cfg.resolved();
assert_eq!(
resolved.stall_threshold_seconds,
AutoApproveConfig::MIN_STALL_THRESHOLD_SECONDS
);
}
#[test]
fn auto_approve_safe_preset_keeps_defaults() {
let cfg = AutoApproveConfig {
approval_level: ApprovalLevelPreset::Safe,
..AutoApproveConfig::default()
};
let wl = cfg.effective_whitelist();
assert!(wl.iter().any(|c| c == "cargo test"));
assert!(wl.iter().any(|c| c == "git push"));
assert!(wl.iter().any(|c| c.starts_with("curl")));
}
#[test]
fn auto_approve_conservative_drops_push_and_curl() {
let cfg = AutoApproveConfig {
approval_level: ApprovalLevelPreset::Conservative,
..AutoApproveConfig::default()
};
let wl = cfg.effective_whitelist();
assert!(wl.iter().any(|c| c == "cargo test"));
assert!(
!wl.iter().any(|c| c.starts_with("git push")),
"conservative drops git push"
);
assert!(
!wl.iter().any(|c| c.starts_with("curl")),
"conservative drops curl"
);
}
#[test]
fn auto_approve_extras_are_unioned_with_defaults() {
let cfg = AutoApproveConfig {
safe_commands: vec!["just lint".to_string(), "just test".to_string()],
..AutoApproveConfig::default()
};
let wl = cfg.effective_whitelist();
assert!(wl.iter().any(|c| c == "cargo fmt"));
assert!(wl.iter().any(|c| c == "just lint"));
assert!(wl.iter().any(|c| c == "just test"));
}
#[test]
fn auto_approve_empty_extras_keep_defaults() {
let cfg = AutoApproveConfig::default();
let wl = cfg.effective_whitelist();
assert!(wl.iter().any(|c| c == "cargo test"));
}
#[test]
fn toml_extras_classify_via_is_safe_command_and_empty_extras_keep_defaults() {
use crate::supervisor::auto_approve::is_safe_command;
let tmp = TempDir::new().unwrap();
let extras_path = tmp.path().join("extras.toml");
write_file(
&extras_path,
"[supervisor]\n\
enabled = true\n\
[supervisor.auto_approve]\n\
safe_commands = [\"just smoke\"]\n",
);
let extras_config = load_config_file(&extras_path).unwrap().unwrap();
let extras_aa = extras_config.supervisor.unwrap().auto_approve.unwrap();
let extras_whitelist = extras_aa.effective_whitelist();
assert!(
is_safe_command("just smoke -v", &extras_whitelist),
"TOML extra `just smoke` must accept `just smoke -v`"
);
assert!(
is_safe_command("cargo test", &extras_whitelist),
"extras must not displace built-in defaults"
);
let empty_path = tmp.path().join("empty.toml");
write_file(
&empty_path,
"[supervisor]\n\
enabled = true\n\
[supervisor.auto_approve]\n\
safe_commands = []\n",
);
let empty_config = load_config_file(&empty_path).unwrap().unwrap();
let empty_aa = empty_config.supervisor.unwrap().auto_approve.unwrap();
let empty_whitelist = empty_aa.effective_whitelist();
assert!(
is_safe_command("cargo test", &empty_whitelist),
"empty safe_commands must keep built-in defaults"
);
assert!(
is_safe_command("cargo fmt --check", &empty_whitelist),
"empty safe_commands must keep `cargo fmt` default"
);
assert!(
!is_safe_command("rm -rf /tmp/foo", &empty_whitelist),
"empty safe_commands must not whitelist arbitrary commands"
);
}
#[test]
fn v030_config_loads_without_auto_approve() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"default_cli = \"claude\"\nmouse = true\n[broker]\nenabled = true\n",
);
let config = load_config_file(&path).unwrap().unwrap();
assert!(config.supervisor.is_none());
assert!(config.broker.enabled);
}
}