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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submit_delay_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings_path: 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 GovernanceConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adr: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub test_strategy: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dod: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constitution: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub readme: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docs: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct McpConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<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, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RoleGatingMode {
#[default]
Warn,
Block,
Off,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct OpsxConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_gating: Option<RoleGatingMode>,
}
impl OpsxConfig {
#[must_use]
pub fn role_gating_mode(&self) -> RoleGatingMode {
self.role_gating.unwrap_or_default()
}
}
#[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,
#[serde(default)]
pub broker_log: BrokerLogConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BrokerLogConfig {
#[serde(default = "BrokerLogConfig::default_max_messages")]
pub max_messages: usize,
#[serde(default = "BrokerLogConfig::default_visible")]
pub default_visible: bool,
}
impl Default for BrokerLogConfig {
fn default() -> Self {
Self {
max_messages: Self::default_max_messages(),
default_visible: Self::default_visible(),
}
}
}
impl BrokerLogConfig {
fn default_max_messages() -> usize {
500
}
fn default_visible() -> bool {
true
}
}
#[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, skip_serializing_if = "Option::is_none")]
pub lint_command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build_command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc_build_command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc_tool_command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec_validate_command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fmt_check_command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security_audit_command: Option<String>,
#[serde(default)]
pub agent_approval: ApprovalLevel,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_approve: Option<AutoApproveConfig>,
#[serde(default)]
pub conflict: ConflictConfig,
#[serde(default)]
pub learnings: bool,
#[serde(default)]
pub learnings_config: LearningsConfig,
#[serde(default)]
pub common_dev_allowlist: CommonDevAllowlistConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify_on_commit_nudge: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strict_branch_guard: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_revert: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manual_approvals_log: Option<bool>,
#[serde(default, skip_serializing_if = "TellConfig::is_default")]
pub tell: TellConfig,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum TellMode {
#[default]
Feedback,
SendKeys,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TellConfig {
#[serde(default)]
pub mode: TellMode,
#[serde(default = "TellConfig::default_inventory_max_age_seconds")]
pub inventory_max_age_seconds: u64,
}
impl Default for TellConfig {
fn default() -> Self {
Self {
mode: TellMode::default(),
inventory_max_age_seconds: Self::default_inventory_max_age_seconds(),
}
}
}
impl TellConfig {
fn default_inventory_max_age_seconds() -> u64 {
60
}
#[must_use]
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
impl SupervisorConfig {
#[must_use]
pub fn strict_branch_guard(&self) -> bool {
self.strict_branch_guard.unwrap_or(true)
}
#[must_use]
pub fn auto_revert(&self) -> bool {
self.auto_revert.unwrap_or(false)
}
#[must_use]
pub fn manual_approvals_log_enabled(&self) -> bool {
self.manual_approvals_log.unwrap_or(true)
}
#[must_use]
pub fn gate_commands(&self) -> crate::skills::GateCommands<'_> {
crate::skills::GateCommands {
test_command: self.test_command.as_deref(),
lint_command: self.lint_command.as_deref(),
build_command: self.build_command.as_deref(),
doc_build_command: self.doc_build_command.as_deref(),
spec_validate_command: self.spec_validate_command.as_deref(),
fmt_check_command: self.fmt_check_command.as_deref(),
security_audit_command: self.security_audit_command.as_deref(),
doc_tool_command: self.doc_tool_command.as_deref(),
}
}
#[must_use]
pub fn verify_on_commit_nudge_enabled(&self) -> bool {
self.verify_on_commit_nudge.unwrap_or(true)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommonDevAllowlistConfig {
#[serde(default = "CommonDevAllowlistConfig::default_enabled")]
pub enabled: bool,
#[serde(default)]
pub extra: Vec<String>,
}
impl Default for CommonDevAllowlistConfig {
fn default() -> Self {
Self {
enabled: Self::default_enabled(),
extra: Vec::new(),
}
}
}
impl CommonDevAllowlistConfig {
fn default_enabled() -> bool {
true
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LearningsConfig {
#[serde(default = "LearningsConfig::default_flush_interval_seconds")]
pub flush_interval_seconds: u64,
#[serde(default)]
pub broker_publish: BrokerPublish,
}
impl Default for LearningsConfig {
fn default() -> Self {
Self {
flush_interval_seconds: Self::default_flush_interval_seconds(),
broker_publish: BrokerPublish::default(),
}
}
}
impl LearningsConfig {
fn default_flush_interval_seconds() -> u64 {
60
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum BrokerPublish {
#[default]
Auto,
ForceOff,
}
impl BrokerPublish {
#[must_use]
pub fn resolve(self, broker_enabled: bool) -> bool {
match self {
Self::Auto => broker_enabled,
Self::ForceOff => false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ConflictConfig {
#[serde(default = "ConflictConfig::default_window_seconds")]
pub window_seconds: u64,
#[serde(default = "ConflictConfig::default_true")]
pub warn_on_intent_overlap: bool,
#[serde(default = "ConflictConfig::default_true")]
pub escalate_on_violation: bool,
}
impl Default for ConflictConfig {
fn default() -> Self {
Self {
window_seconds: Self::default_window_seconds(),
warn_on_intent_overlap: true,
escalate_on_violation: true,
}
}
}
impl ConflictConfig {
fn default_window_seconds() -> u64 {
120
}
fn default_true() -> bool {
true
}
}
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approve_worktree_writes: Option<bool>,
}
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,
approve_worktree_writes: None,
}
}
}
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 approve_worktree_writes(&self) -> bool {
self.approve_worktree_writes.unwrap_or(true)
}
#[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, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct WatcherConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub republish_working_ttl_seconds: Option<u64>,
}
impl WatcherConfig {
pub const DEFAULT_REPUBLISH_TTL_SECONDS: u64 = 60;
pub const MIN_REPUBLISH_TTL_SECONDS: u64 = 5;
#[must_use]
pub fn republish_working_ttl_seconds(&self) -> u64 {
match self.republish_working_ttl_seconds {
None => Self::DEFAULT_REPUBLISH_TTL_SECONDS,
Some(0) => 0,
Some(n) if n < Self::MIN_REPUBLISH_TTL_SECONDS => {
eprintln!(
"warning: [broker.watcher] republish_working_ttl_seconds = {n} clamped to {}s minimum",
Self::MIN_REPUBLISH_TTL_SECONDS
);
Self::MIN_REPUBLISH_TTL_SECONDS
}
Some(n) => n,
}
}
}
#[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,
#[serde(default)]
pub watcher: WatcherConfig,
}
impl Default for BrokerConfig {
fn default() -> Self {
Self {
enabled: false,
port: 9119,
bind: "127.0.0.1".to_string(),
watcher: WatcherConfig::default(),
}
}
}
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 LayoutConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub border_affordances: Option<bool>,
}
impl LayoutConfig {
#[must_use]
pub fn border_affordances_enabled(&self) -> bool {
self.border_affordances.unwrap_or(true)
}
}
#[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>,
#[serde(default)]
pub governance: GovernanceConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout: Option<LayoutConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opsx: Option<OpsxConfig>,
#[serde(default)]
pub mcp: McpConfig,
}
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()),
governance: GovernanceConfig {
adr: overlay
.governance
.adr
.clone()
.or_else(|| self.governance.adr.clone()),
test_strategy: overlay
.governance
.test_strategy
.clone()
.or_else(|| self.governance.test_strategy.clone()),
security: overlay
.governance
.security
.clone()
.or_else(|| self.governance.security.clone()),
dod: overlay
.governance
.dod
.clone()
.or_else(|| self.governance.dod.clone()),
constitution: overlay
.governance
.constitution
.clone()
.or_else(|| self.governance.constitution.clone()),
readme: overlay
.governance
.readme
.clone()
.or_else(|| self.governance.readme.clone()),
docs: overlay
.governance
.docs
.clone()
.or_else(|| self.governance.docs.clone()),
},
layout: overlay.layout.clone().or_else(|| self.layout.clone()),
opsx: overlay.opsx.clone().or_else(|| self.opsx.clone()),
mcp: McpConfig {
name: overlay.mcp.name.clone().or_else(|| self.mcp.name.clone()),
},
}
}
#[must_use]
pub fn role_gating_mode(&self) -> RoleGatingMode {
self.opsx
.as_ref()
.map(OpsxConfig::role_gating_mode)
.unwrap_or_default()
}
#[must_use]
pub fn border_affordances_enabled(&self) -> bool {
self.layout
.as_ref()
.is_none_or(LayoutConfig::border_affordances_enabled)
}
#[must_use]
pub fn mcp_server_name(&self) -> String {
self.mcp
.name
.clone()
.unwrap_or_else(|| "git-paw".to_string())
}
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> {
let mut config = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
auto_wire_governance(&mut config, repo_root);
Ok(config)
}
fn auto_wire_governance(config: &mut PawConfig, repo_root: &Path) {
if config.governance.constitution.is_some() {
return;
}
let Some(specs_cfg) = config.specs.as_ref() else {
return;
};
let Some(spec_type) = specs_cfg.spec_type.as_deref() else {
return;
};
if spec_type != "speckit" {
return;
}
let dir = specs_cfg.dir.as_deref().unwrap_or("specs");
let specs_dir = repo_root.join(dir);
if let Some(detected) = crate::specs::speckit::detect_constitution(&specs_dir) {
config.governance.constitution = Some(detected);
}
}
pub fn load_config(
repo_root: &Path,
user_config_path: Option<&Path>,
) -> Result<PawConfig, PawError> {
let global_path = match user_config_path {
Some(p) => p.to_path_buf(),
None => 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();
let mut merged = global.merged_with(&repo);
auto_wire_governance(&mut merged, repo_root);
Ok(merged)
}
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),
submit_delay_ms: None,
settings_path: None,
},
);
save_config_to(config_path, &config)
}
#[allow(clippy::too_many_lines)] 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 running configured gate
# commands during the five-gate verification workflow.
#
# Gate command templates feed the supervisor skill's five gates: gate 1
# Testing (fmt_check / lint / build / test), gate 3 Spec audit
# (spec_validate), gate 4 Doc audit (doc_build), gate 5 Security audit
# (security_audit). When a key is omitted, the matching placeholder
# renders as `(not configured)` in the supervisor skill and the agent
# skips that tooling step (the gate's manual review still applies).
# `{{CHANGE_ID}}` inside spec_validate_command is substituted by the
# supervisor agent at verification time with the change name.
# [supervisor]
# enabled = true
# cli = "claude"
# test_command = "just check" # or: "cargo test", "npm test", "pytest"
# lint_command = "cargo clippy -- -D warnings" # or: "npm run lint", "ruff check .", "golangci-lint run"
# build_command = "cargo build" # or: "npm run build", "mvn package", "go build ./..."
# fmt_check_command = "cargo fmt --check" # or: "prettier --check .", "gofmt -l ."
# doc_build_command = "mdbook build docs/" # or: "sphinx-build", "mkdocs build"
# doc_tool_command = "cargo doc --no-deps" # or: "sphinx-build -W docs docs/_build", "javadoc", "npx typedoc"
# spec_validate_command = "openspec validate {{CHANGE_ID}} --strict" # OpenSpec only
# security_audit_command = "cargo audit" # or: "npm audit", "bandit -r ."
# agent_approval = "auto" # one of: "manual", "auto", "full-auto"
# verify_on_commit_nudge = true # broker nudges the supervisor to verify each commit promptly (default true)
#
# Routing through the supervisor (the /tell and /agents commands). The user
# types in the supervisor pane and the supervisor routes the prompt to the
# named agent. `mode` selects the default delivery channel:
# "feedback" (default) — queue an agent.feedback; the agent picks it up on
# its next inbox poll. Safe for mixed-mode sessions.
# "send-keys" — inject the prompt directly into the target pane;
# used only when the target is in accept-edits mode,
# otherwise /tell falls back to feedback.
# `inventory_max_age_seconds` is how stale the cached /agents inventory may be
# before /tell or /agents re-polls the broker (default 60).
# [supervisor.tell]
# mode = "feedback"
# inventory_max_age_seconds = 60
#
# Conflict detector tuning. Active only when supervisor mode is enabled.
# [supervisor.conflict]
# window_seconds = 120 # escalate unresolved in-flight conflicts after this many seconds
# warn_on_intent_overlap = true # emit feedback when two agent.intent declarations overlap
# escalate_on_violation = true # also publish agent.question to supervisor on ownership violations
# Common dev-command allowlist. When supervisor mode starts a session,
# git-paw seeds .claude/settings.json::allowed_bash_prefixes with a
# curated preset (cargo, git, just, mdbook, openspec, find, grep, sed -n)
# so agents do not hit a permission prompt for each variant. Opt out by
# setting enabled = false; extend with project-specific prefixes via extra.
# [supervisor.common_dev_allowlist]
# enabled = true
# extra = ["pnpm test", "deno fmt"]
# opsx (OpenSpec) role gating. When the session's spec engine is OpenSpec,
# git-paw's post-commit guard detects archive activity (`/opsx:archive` /
# `openspec archive`) by a non-supervisor agent and reacts per this mode:
# "warn" (default) — feedback to the offending agent + a permission_pattern
# learning the user sees in learnings.
# "block" — warn behaviour PLUS a feedback to the supervisor
# requesting it revert the offending commit.
# "off" — guard disabled entirely.
# The guard is inert under non-OpenSpec engines (speckit, markdown).
# [opsx]
# role_gating = "warn"
# 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()),
submit_delay_ms: None,
settings_path: None,
},
)]),
presets: HashMap::from([(
"dev".into(),
Preset {
branches: vec!["main".into()],
cli: "claude".into(),
},
)]),
specs: None,
logging: None,
dashboard: None,
broker: BrokerConfig::default(),
supervisor: None,
governance: GovernanceConfig::default(),
layout: None,
opsx: None,
mcp: McpConfig::default(),
};
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(),
..Default::default()
};
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 verify_on_commit_nudge_defaults_true_when_absent() {
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_eq!(
supervisor.verify_on_commit_nudge, None,
"an omitted field must deserialise as None"
);
assert!(
supervisor.verify_on_commit_nudge_enabled(),
"an unset verify_on_commit_nudge must resolve to true (default on)"
);
}
#[test]
fn verify_on_commit_nudge_explicit_false_disables() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\nenabled = true\nverify_on_commit_nudge = false\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert_eq!(supervisor.verify_on_commit_nudge, Some(false));
assert!(
!supervisor.verify_on_commit_nudge_enabled(),
"an explicit `false` must disable the nudge"
);
}
#[test]
fn verify_on_commit_nudge_explicit_true_enables() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\nenabled = true\nverify_on_commit_nudge = true\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert_eq!(supervisor.verify_on_commit_nudge, Some(true));
assert!(supervisor.verify_on_commit_nudge_enabled());
}
#[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()),
lint_command: None,
build_command: None,
doc_build_command: None,
doc_tool_command: None,
spec_validate_command: None,
fmt_check_command: None,
security_audit_command: None,
agent_approval: ApprovalLevel::FullAuto,
auto_approve: None,
conflict: ConflictConfig::default(),
learnings: false,
learnings_config: LearningsConfig::default(),
common_dev_allowlist: CommonDevAllowlistConfig::default(),
verify_on_commit_nudge: None,
strict_branch_guard: None,
auto_revert: None,
manual_approvals_log: None,
tell: TellConfig::default(),
}),
..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 manual_approvals_log_defaults_to_true_when_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[supervisor]\nenabled = true\n");
let cfg = load_config_file(&path).unwrap().unwrap();
let sup = cfg.supervisor.unwrap();
assert_eq!(sup.manual_approvals_log, None);
assert!(
sup.manual_approvals_log_enabled(),
"absent field must resolve to true"
);
}
#[test]
fn manual_approvals_log_explicit_false_opts_out() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\nenabled = true\nmanual_approvals_log = false\n",
);
let cfg = load_config_file(&path).unwrap().unwrap();
let sup = cfg.supervisor.unwrap();
assert_eq!(sup.manual_approvals_log, Some(false));
assert!(!sup.manual_approvals_log_enabled());
}
#[test]
fn pre_v050_config_parses_with_manual_approvals_log_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\nenabled = true\ncli = \"claude\"\nlearnings = true\n",
);
let cfg = load_config_file(&path).unwrap().unwrap();
let sup = cfg.supervisor.unwrap();
assert_eq!(sup.manual_approvals_log, None);
assert!(sup.manual_approvals_log_enabled());
}
#[test]
fn strict_branch_guard_defaults_to_true_and_honours_opt_out() {
let on = TempDir::new().unwrap();
let on_path = on.path().join("config.toml");
write_file(&on_path, "[supervisor]\nenabled = true\n");
let cfg = load_config_file(&on_path).unwrap().unwrap();
let sup = cfg.supervisor.unwrap();
assert_eq!(sup.strict_branch_guard, None);
assert!(sup.strict_branch_guard(), "default must resolve to true");
let off = TempDir::new().unwrap();
let off_path = off.path().join("config.toml");
write_file(
&off_path,
"[supervisor]\nenabled = true\nstrict_branch_guard = false\n",
);
let cfg = load_config_file(&off_path).unwrap().unwrap();
let sup = cfg.supervisor.unwrap();
assert_eq!(sup.strict_branch_guard, Some(false));
assert!(!sup.strict_branch_guard());
}
#[test]
fn gate_command_fields_default_to_none() {
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_eq!(supervisor.test_command, None);
assert_eq!(supervisor.lint_command, None);
assert_eq!(supervisor.build_command, None);
assert_eq!(supervisor.doc_build_command, None);
assert_eq!(supervisor.doc_tool_command, None);
assert_eq!(supervisor.spec_validate_command, None);
assert_eq!(supervisor.fmt_check_command, None);
assert_eq!(supervisor.security_audit_command, None);
}
#[test]
fn gate_command_fields_round_trip() {
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()),
lint_command: Some("cargo clippy -- -D warnings".into()),
build_command: Some("cargo build".into()),
doc_build_command: Some("mdbook build docs/".into()),
doc_tool_command: Some("cargo doc --no-deps".into()),
spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict".into()),
fmt_check_command: Some("cargo fmt --check".into()),
security_audit_command: Some("cargo audit".into()),
..Default::default()
}),
..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 gate_command_fields_omit_from_toml_when_none() {
let supervisor = SupervisorConfig {
enabled: true,
test_command: None,
lint_command: None,
build_command: None,
doc_build_command: None,
doc_tool_command: None,
spec_validate_command: None,
fmt_check_command: None,
security_audit_command: None,
..Default::default()
};
let serialized = toml::to_string_pretty(&supervisor).unwrap();
for key in [
"test_command",
"lint_command",
"build_command",
"doc_build_command",
"doc_tool_command",
"spec_validate_command",
"fmt_check_command",
"security_audit_command",
] {
assert!(
!serialized.contains(key),
"TOML serialised with None gate fields should omit `{key}`; got:\n{serialized}",
);
}
}
#[test]
fn doc_tool_command_default_none() {
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_eq!(supervisor.doc_tool_command, None);
}
#[test]
fn doc_tool_command_explicit_value_preserved() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\n\
enabled = true\n\
doc_tool_command = \"sphinx-build -W docs docs/_build\"\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert_eq!(
supervisor.doc_tool_command.as_deref(),
Some("sphinx-build -W docs docs/_build"),
"explicit doc_tool_command value (including all whitespace) must be preserved verbatim",
);
}
#[test]
fn doc_tool_command_v0_5_config_parses_without_field() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\n\
enabled = true\n\
test_command = \"just check\"\n\
lint_command = \"cargo clippy -- -D warnings\"\n\
build_command = \"cargo build\"\n\
doc_build_command = \"mdbook build docs/\"\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert_eq!(supervisor.doc_tool_command, None);
assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
}
#[test]
fn doc_tool_command_flows_into_gate_commands() {
let supervisor = SupervisorConfig {
doc_tool_command: Some("javadoc -d docs/api src/**/*.java".into()),
..Default::default()
};
let gates = supervisor.gate_commands();
assert_eq!(
gates.doc_tool_command,
Some("javadoc -d docs/api src/**/*.java"),
);
}
#[test]
fn supervisor_common_dev_allowlist_defaults_when_section_absent() {
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.common_dev_allowlist.enabled);
assert!(supervisor.common_dev_allowlist.extra.is_empty());
}
#[test]
fn supervisor_common_dev_allowlist_disabled_opt_out() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\nenabled = true\n\
[supervisor.common_dev_allowlist]\nenabled = false\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert!(!supervisor.common_dev_allowlist.enabled);
assert!(supervisor.common_dev_allowlist.extra.is_empty());
}
#[test]
fn supervisor_common_dev_allowlist_extra_parsed() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\nenabled = true\n\
[supervisor.common_dev_allowlist]\nextra = [\"pnpm test\", \"deno fmt\"]\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert_eq!(
supervisor.common_dev_allowlist.extra,
vec!["pnpm test".to_string(), "deno fmt".to_string()],
);
assert!(supervisor.common_dev_allowlist.enabled);
}
#[test]
fn supervisor_common_dev_allowlist_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,
common_dev_allowlist: CommonDevAllowlistConfig {
enabled: false,
extra: vec!["pnpm test".into(), "uv pip install".into()],
},
..Default::default()
}),
..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_pre_v05_config_loads_with_default_common_dev_allowlist() {
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 = \"auto\"\n\
[supervisor.conflict]\n\
window_seconds = 60\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert!(supervisor.common_dev_allowlist.enabled);
assert!(supervisor.common_dev_allowlist.extra.is_empty());
}
#[test]
fn generated_default_config_template_contains_common_dev_allowlist_section() {
let template = generate_default_config();
assert!(
template.contains("[supervisor.common_dev_allowlist]"),
"default template should document the new sub-table",
);
assert!(
template.contains("enabled = true"),
"template should show the enabled default",
);
assert!(
template.contains("extra ="),
"template should illustrate the extra field",
);
}
#[test]
fn learnings_defaults_to_false_when_supervisor_section_absent_field() {
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.learnings);
assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
}
#[test]
fn learnings_true_loads() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[supervisor]\nenabled = true\nlearnings = true\n");
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert!(supervisor.learnings);
assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
}
#[test]
fn learnings_config_custom_flush_interval_is_honoured() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\n\
enabled = true\n\
learnings = true\n\
[supervisor.learnings_config]\n\
flush_interval_seconds = 30\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert!(supervisor.learnings);
assert_eq!(supervisor.learnings_config.flush_interval_seconds, 30);
}
#[test]
fn learnings_config_defaults_when_table_absent() {
let cfg = LearningsConfig::default();
assert_eq!(cfg.flush_interval_seconds, 60);
}
#[test]
fn pre_v050_config_loads_with_learnings_false() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"default_cli = \"claude\"\n\
[supervisor]\n\
enabled = true\n\
agent_approval = \"auto\"\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert!(!supervisor.learnings);
assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
}
#[test]
fn learnings_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,
learnings: true,
learnings_config: LearningsConfig {
flush_interval_seconds: 90,
broker_publish: BrokerPublish::ForceOff,
},
..Default::default()
}),
..Default::default()
};
save_config_to(&config_path, &original).unwrap();
let loaded = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(loaded.supervisor, original.supervisor);
let supervisor = loaded.supervisor.unwrap();
assert!(supervisor.learnings);
assert_eq!(supervisor.learnings_config.flush_interval_seconds, 90);
}
#[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()
}),
..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 broker_log_config_defaults() {
let cfg = BrokerLogConfig::default();
assert_eq!(cfg.max_messages, 500);
assert!(cfg.default_visible);
}
#[test]
fn dashboard_config_default_includes_broker_log_defaults() {
let cfg = DashboardConfig::default();
assert_eq!(cfg.broker_log.max_messages, 500);
assert!(cfg.broker_log.default_visible);
}
#[test]
fn parses_broker_log_section_with_explicit_overrides() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[dashboard.broker_log]\nmax_messages = 100\ndefault_visible = false\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let dashboard = config.dashboard.unwrap();
assert_eq!(dashboard.broker_log.max_messages, 100);
assert!(!dashboard.broker_log.default_visible);
}
#[test]
fn broker_log_partial_section_fills_remaining_defaults() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[dashboard.broker_log]\nmax_messages = 42\n");
let config = load_config_file(&path).unwrap().unwrap();
let broker_log = config.dashboard.unwrap().broker_log;
assert_eq!(broker_log.max_messages, 42);
assert!(
broker_log.default_visible,
"default_visible must fall back to true when omitted"
);
}
#[test]
fn v050_dashboard_section_without_broker_log_still_parses() {
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);
assert_eq!(dashboard.broker_log, BrokerLogConfig::default());
}
#[test]
fn broker_log_round_trips_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: false,
broker_log: BrokerLogConfig {
max_messages: 250,
default_visible: false,
},
}),
..Default::default()
};
save_config_to(&config_path, &original).unwrap();
let loaded = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(loaded.dashboard, original.dashboard);
}
#[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()
}),
..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()
},
..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 watcher_ttl_defaults_to_sixty_when_absent() {
let cfg = WatcherConfig::default();
assert_eq!(cfg.republish_working_ttl_seconds(), 60);
}
#[test]
fn watcher_ttl_zero_disables() {
let cfg = WatcherConfig {
republish_working_ttl_seconds: Some(0),
};
assert_eq!(cfg.republish_working_ttl_seconds(), 0);
}
#[test]
fn watcher_ttl_below_floor_clamps_to_five() {
let cfg = WatcherConfig {
republish_working_ttl_seconds: Some(2),
};
assert_eq!(
cfg.republish_working_ttl_seconds(),
WatcherConfig::MIN_REPUBLISH_TTL_SECONDS
);
}
#[test]
fn watcher_ttl_explicit_non_zero_is_preserved() {
let cfg = WatcherConfig {
republish_working_ttl_seconds: Some(120),
};
assert_eq!(cfg.republish_working_ttl_seconds(), 120);
}
#[test]
fn watcher_ttl_parses_from_broker_table() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[broker]\nenabled = true\n[broker.watcher]\nrepublish_working_ttl_seconds = 0\n",
);
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(config.broker.watcher.republish_working_ttl_seconds, Some(0));
assert_eq!(config.broker.watcher.republish_working_ttl_seconds(), 0);
}
#[test]
fn approve_worktree_writes_defaults_to_true_when_absent() {
let cfg = AutoApproveConfig::default();
assert!(
cfg.approve_worktree_writes(),
"absent approve_worktree_writes must resolve to true"
);
}
#[test]
fn approve_worktree_writes_explicit_false_resolves_false() {
let cfg = AutoApproveConfig {
approve_worktree_writes: Some(false),
..AutoApproveConfig::default()
};
assert!(!cfg.approve_worktree_writes());
}
#[test]
fn approve_worktree_writes_parses_from_toml() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\nenabled = true\n[supervisor.auto_approve]\napprove_worktree_writes = false\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let aa = config.supervisor.unwrap().auto_approve.unwrap();
assert_eq!(aa.approve_worktree_writes, Some(false));
assert!(!aa.approve_worktree_writes());
}
#[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 conflict_config_defaults_match_spec() {
let cfg = ConflictConfig::default();
assert_eq!(cfg.window_seconds, 120);
assert!(cfg.warn_on_intent_overlap);
assert!(cfg.escalate_on_violation);
}
#[test]
fn supervisor_with_no_conflict_section_loads_defaults() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[supervisor]\nenabled = true\n");
let supervisor = load_config_file(&path)
.unwrap()
.unwrap()
.supervisor
.unwrap();
assert_eq!(supervisor.conflict.window_seconds, 120);
assert!(supervisor.conflict.warn_on_intent_overlap);
assert!(supervisor.conflict.escalate_on_violation);
}
#[test]
fn conflict_section_with_all_fields_overrides_defaults() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\n\
enabled = true\n\
[supervisor.conflict]\n\
window_seconds = 300\n\
warn_on_intent_overlap = false\n\
escalate_on_violation = false\n",
);
let conflict = load_config_file(&path)
.unwrap()
.unwrap()
.supervisor
.unwrap()
.conflict;
assert_eq!(conflict.window_seconds, 300);
assert!(!conflict.warn_on_intent_overlap);
assert!(!conflict.escalate_on_violation);
}
#[test]
fn conflict_section_with_partial_fields_keeps_other_defaults() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[supervisor]\n[supervisor.conflict]\nwindow_seconds = 60\n",
);
let conflict = load_config_file(&path)
.unwrap()
.unwrap()
.supervisor
.unwrap()
.conflict;
assert_eq!(conflict.window_seconds, 60);
assert!(conflict.warn_on_intent_overlap);
assert!(conflict.escalate_on_violation);
}
#[test]
fn pre_v05_config_without_conflict_section_loads() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"default_cli = \"claude\"\n\
[supervisor]\n\
enabled = true\n\
agent_approval = \"auto\"\n",
);
let config = load_config_file(&path).unwrap().unwrap();
let supervisor = config.supervisor.unwrap();
assert!(supervisor.enabled);
assert_eq!(supervisor.conflict, ConflictConfig::default());
}
#[test]
fn conflict_config_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,
conflict: ConflictConfig {
window_seconds: 90,
warn_on_intent_overlap: false,
escalate_on_violation: true,
},
..Default::default()
}),
..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 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);
}
fn write_repo_config(repo_root: &Path, toml: &str) {
write_file(&repo_config_path(repo_root), toml);
}
fn missing_global(tmp: &TempDir) -> PathBuf {
tmp.path().join("nonexistent-global").join("config.toml")
}
#[test]
fn governance_defaults_to_all_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.governance.adr.is_none());
assert!(config.governance.test_strategy.is_none());
assert!(config.governance.security.is_none());
assert!(config.governance.dod.is_none());
assert!(config.governance.constitution.is_none());
}
#[test]
fn governance_all_paths_populated() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[governance]\n\
adr = \"docs/adr\"\n\
test_strategy = \"docs/test-strategy.md\"\n\
security = \"docs/security-checklist.md\"\n\
dod = \"docs/definition-of-done.md\"\n\
constitution = \".specify/memory/constitution.md\"\n",
);
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(
config.governance.adr.as_deref(),
Some(Path::new("docs/adr"))
);
assert_eq!(
config.governance.test_strategy.as_deref(),
Some(Path::new("docs/test-strategy.md"))
);
assert_eq!(
config.governance.security.as_deref(),
Some(Path::new("docs/security-checklist.md"))
);
assert_eq!(
config.governance.dod.as_deref(),
Some(Path::new("docs/definition-of-done.md"))
);
assert_eq!(
config.governance.constitution.as_deref(),
Some(Path::new(".specify/memory/constitution.md"))
);
}
#[test]
fn governance_partial_paths_only_some_fields_populated() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[governance]\n\
dod = \"docs/dod.md\"\n\
security = \"docs/security.md\"\n",
);
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(
config.governance.dod.as_deref(),
Some(Path::new("docs/dod.md"))
);
assert_eq!(
config.governance.security.as_deref(),
Some(Path::new("docs/security.md"))
);
assert!(config.governance.adr.is_none());
assert!(config.governance.test_strategy.is_none());
assert!(config.governance.constitution.is_none());
}
#[test]
fn governance_absolute_path_preserved_as_is() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[governance]\nadr = \"/absolute/path/to/adr\"\n");
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(
config.governance.adr,
Some(PathBuf::from("/absolute/path/to/adr"))
);
}
#[test]
fn governance_nonexistent_path_loads_cleanly() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[governance]\ndod = \"docs/never-existed.md\"\n");
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(
config.governance.dod,
Some(PathBuf::from("docs/never-existed.md"))
);
}
#[test]
fn governance_round_trips_through_save_and_load() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let original = PawConfig {
governance: GovernanceConfig {
adr: Some(PathBuf::from("docs/adr")),
test_strategy: Some(PathBuf::from("docs/test-strategy.md")),
security: Some(PathBuf::from("docs/security.md")),
dod: Some(PathBuf::from("docs/dod.md")),
constitution: Some(PathBuf::from(".specify/memory/constitution.md")),
readme: Some(PathBuf::from("README.md")),
docs: Some(PathBuf::from("docs/src")),
},
..Default::default()
};
save_config_to(&config_path, &original).unwrap();
let loaded = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(loaded.governance, original.governance);
}
#[test]
fn governance_v04_config_without_section_loads_with_defaults() {
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\
[supervisor]\n\
enabled = true\n\
[specs]\n\
dir = \"specs\"\n\
type = \"openspec\"\n\
[clis.foo]\n\
command = \"/bin/foo\"\n",
);
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(config.governance, GovernanceConfig::default());
assert!(config.governance.adr.is_none());
assert!(config.governance.test_strategy.is_none());
assert!(config.governance.security.is_none());
assert!(config.governance.dod.is_none());
assert!(config.governance.constitution.is_none());
assert!(config.governance.readme.is_none());
assert!(config.governance.docs.is_none());
}
#[test]
fn governance_default_has_only_path_fields() {
let GovernanceConfig {
adr,
test_strategy,
security,
dod,
constitution,
readme,
docs,
} = GovernanceConfig::default();
assert!(adr.is_none());
assert!(test_strategy.is_none());
assert!(security.is_none());
assert!(dod.is_none());
assert!(constitution.is_none());
assert!(readme.is_none());
assert!(docs.is_none());
}
#[test]
fn governance_parses_readme_and_docs_fields() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
"[governance]\n\
readme = \"README.md\"\n\
docs = \"docs/src\"\n",
);
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(config.governance.readme, Some(PathBuf::from("README.md")));
assert_eq!(config.governance.docs, Some(PathBuf::from("docs/src")));
}
#[test]
fn governance_readme_and_docs_default_to_none_when_omitted() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "[governance]\ndod = \"docs/dod.md\"\n");
let config = load_config_file(&path).unwrap().unwrap();
assert!(config.governance.readme.is_none());
assert!(config.governance.docs.is_none());
assert_eq!(config.governance.dod, Some(PathBuf::from("docs/dod.md")));
}
#[test]
fn governance_readme_and_docs_round_trip() {
let original = GovernanceConfig {
readme: Some(PathBuf::from("README.md")),
docs: Some(PathBuf::from("docs/src")),
..Default::default()
};
let toml_str = toml::to_string(&original).unwrap();
let reparsed: GovernanceConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(reparsed.readme, original.readme);
assert_eq!(reparsed.docs, original.docs);
}
#[test]
fn governance_auto_wires_constitution_when_speckit_detected() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("repo");
let specify = repo_root.join(".specify");
let specs = specify.join("specs");
let memory = specify.join("memory");
fs::create_dir_all(&specs).unwrap();
fs::create_dir_all(&memory).unwrap();
let constitution = memory.join("constitution.md");
fs::write(&constitution, "# Constitution\n").unwrap();
write_repo_config(
&repo_root,
"[specs]\n\
type = \"speckit\"\n\
dir = \".specify/specs\"\n",
);
let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
assert_eq!(
config.governance.constitution.as_deref(),
Some(constitution.as_path())
);
}
#[test]
fn governance_explicit_constitution_preserved_over_auto_wiring() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("repo");
let specify = repo_root.join(".specify");
let specs = specify.join("specs");
let memory = specify.join("memory");
fs::create_dir_all(&specs).unwrap();
fs::create_dir_all(&memory).unwrap();
fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
write_repo_config(
&repo_root,
"[specs]\n\
type = \"speckit\"\n\
dir = \".specify/specs\"\n\
[governance]\n\
constitution = \"docs/principles.md\"\n",
);
let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
assert_eq!(
config.governance.constitution,
Some(PathBuf::from("docs/principles.md"))
);
}
#[test]
fn governance_auto_wiring_skipped_when_specs_type_is_openspec() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("repo");
let specify = repo_root.join(".specify");
let memory = specify.join("memory");
fs::create_dir_all(&memory).unwrap();
fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
fs::create_dir_all(repo_root.join("specs")).unwrap();
write_repo_config(
&repo_root,
"[specs]\n\
type = \"openspec\"\n\
dir = \"specs\"\n",
);
let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
assert!(config.governance.constitution.is_none());
}
#[test]
fn governance_auto_wiring_skipped_when_specs_section_absent() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("repo");
let memory = repo_root.join(".specify").join("memory");
fs::create_dir_all(&memory).unwrap();
fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
fs::create_dir_all(repo_root.join(".git-paw")).unwrap();
write_repo_config(&repo_root, "default_cli = \"claude\"\n");
let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
assert!(config.governance.constitution.is_none());
}
#[test]
fn governance_auto_wiring_skipped_when_constitution_md_absent() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("repo");
let specs = repo_root.join(".specify").join("specs");
fs::create_dir_all(&specs).unwrap();
write_repo_config(
&repo_root,
"[specs]\n\
type = \"speckit\"\n\
dir = \".specify/specs\"\n",
);
let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
assert!(config.governance.constitution.is_none());
}
#[test]
fn governance_explicit_empty_string_constitution_suppresses_auto_wiring() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("repo");
let specify = repo_root.join(".specify");
let specs = specify.join("specs");
let memory = specify.join("memory");
fs::create_dir_all(&specs).unwrap();
fs::create_dir_all(&memory).unwrap();
fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
write_repo_config(
&repo_root,
"[specs]\n\
type = \"speckit\"\n\
dir = \".specify/specs\"\n\
[governance]\n\
constitution = \"\"\n",
);
let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
assert_eq!(config.governance.constitution, Some(PathBuf::from("")));
}
#[test]
fn governance_merge_fields_independently_across_global_and_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, "[governance]\nadr = \"docs/adr\"\n");
write_file(
&repo_config_path(&repo_root),
"[governance]\ndod = \"docs/dod.md\"\n",
);
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.governance.adr, Some(PathBuf::from("docs/adr")));
assert_eq!(config.governance.dod, Some(PathBuf::from("docs/dod.md")));
}
#[test]
fn governance_merge_repo_wins_per_field_when_both_set() {
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, "[governance]\nadr = \"docs/global-adr\"\n");
write_file(
&repo_config_path(&repo_root),
"[governance]\nadr = \"docs/repo-adr\"\n",
);
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.governance.adr, Some(PathBuf::from("docs/repo-adr")));
}
#[test]
fn governance_load_repo_config_also_auto_wires_constitution() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("repo");
let specify = repo_root.join(".specify");
let specs = specify.join("specs");
let memory = specify.join("memory");
fs::create_dir_all(&specs).unwrap();
fs::create_dir_all(&memory).unwrap();
let constitution = memory.join("constitution.md");
fs::write(&constitution, "# Constitution\n").unwrap();
write_repo_config(
&repo_root,
"[specs]\n\
type = \"speckit\"\n\
dir = \".specify/specs\"\n",
);
let config = load_repo_config(&repo_root).unwrap();
assert_eq!(
config.governance.constitution.as_deref(),
Some(constitution.as_path())
);
}
#[test]
fn load_config_with_some_pins_global_to_override_path() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
let global_a = tmp.path().join("global-A.toml");
let global_b = tmp.path().join("global-B.toml");
write_file(&global_a, "[clis.cli-A]\ncommand = \"/bin/a\"\n");
write_file(&global_b, "[clis.cli-B]\ncommand = \"/bin/b\"\n");
let config = load_config(&repo_root, Some(&global_a)).unwrap();
assert!(config.clis.contains_key("cli-A"));
assert!(!config.clis.contains_key("cli-B"));
}
#[test]
fn load_config_with_some_nonexistent_returns_defaults() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
let missing = tmp.path().join("does-not-exist.toml");
let config = load_config(&repo_root, Some(&missing)).unwrap();
assert_eq!(config, PawConfig::default());
}
#[test]
fn load_config_override_does_not_affect_repo_resolution() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&repo_config_path(&repo_root), "default_cli = \"claude\"\n");
let global_path = tmp.path().join("global.toml");
write_file(&global_path, "default_cli = \"gemini\"\n");
let config = load_config(&repo_root, Some(&global_path)).unwrap();
assert_eq!(config.default_cli.as_deref(), Some("claude"));
}
#[test]
fn governance_config_rejects_gates_field() {
let toml_input = "[governance]\ndod = \"docs/dod.md\"\n[governance.gates]\ndod = true\n";
let cfg: PawConfig = toml::from_str(toml_input).expect("toml parse");
let gov = cfg.governance;
assert_eq!(gov.dod.as_deref(), Some(Path::new("docs/dod.md")));
let round_trip = toml::to_string(&gov).expect("serialise gov");
assert!(
!round_trip.contains("gates"),
"GovernanceConfig must not round-trip a `gates` field; got: {round_trip}"
);
assert!(
!round_trip.contains("[governance.gates]"),
"GovernanceConfig must not round-trip a `[governance.gates]` section; got: {round_trip}"
);
}
#[test]
fn border_affordances_defaults_to_true_when_layout_absent() {
let cfg: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("toml parse");
assert!(
cfg.layout.is_none(),
"no [layout] section should parse as None"
);
assert!(
cfg.border_affordances_enabled(),
"border affordances default to on when [layout] is absent"
);
}
#[test]
fn border_affordances_defaults_to_true_when_field_unset() {
let cfg: PawConfig = toml::from_str("[layout]\n").expect("toml parse");
assert!(
cfg.border_affordances_enabled(),
"border affordances default to on when the field is unset"
);
}
#[test]
fn border_affordances_explicit_false_resolves_off() {
let cfg: PawConfig =
toml::from_str("[layout]\nborder_affordances = false\n").expect("toml parse");
assert_eq!(cfg.layout.as_ref().unwrap().border_affordances, Some(false));
assert!(
!cfg.border_affordances_enabled(),
"explicit false must resolve to off"
);
}
#[test]
fn border_affordances_explicit_true_resolves_on() {
let cfg: PawConfig =
toml::from_str("[layout]\nborder_affordances = true\n").expect("toml parse");
assert!(cfg.border_affordances_enabled());
}
#[test]
fn v0_5_0_config_without_layout_parses() {
let v0_5_0 = "default_cli = \"claude\"\nmouse = true\n\n[broker]\nenabled = true\nport = 9119\n\n[supervisor]\nenabled = true\n";
let cfg: PawConfig = toml::from_str(v0_5_0).expect("v0.5.0 config must still parse");
assert!(cfg.layout.is_none());
assert!(cfg.border_affordances_enabled());
}
#[test]
fn layout_overlay_wins_in_merge() {
let base: PawConfig =
toml::from_str("[layout]\nborder_affordances = true\n").expect("base");
let overlay: PawConfig =
toml::from_str("[layout]\nborder_affordances = false\n").expect("overlay");
let merged = base.merged_with(&overlay);
assert!(
!merged.border_affordances_enabled(),
"overlay [layout] must win in the merge"
);
}
#[test]
fn layout_base_preserved_when_overlay_absent() {
let base: PawConfig =
toml::from_str("[layout]\nborder_affordances = false\n").expect("base");
let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
let merged = base.merged_with(&overlay);
assert!(
!merged.border_affordances_enabled(),
"base [layout] must survive when the overlay has none"
);
}
#[test]
fn role_gating_defaults_to_warn_when_section_absent() {
let config: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("parses");
assert!(config.opsx.is_none());
assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
}
#[test]
fn role_gating_section_present_but_field_absent_resolves_warn() {
let config: PawConfig = toml::from_str("[opsx]\n").expect("parses");
assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
}
#[test]
fn role_gating_explicit_warn() {
let config: PawConfig = toml::from_str("[opsx]\nrole_gating = \"warn\"\n").expect("parses");
assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
}
#[test]
fn role_gating_explicit_block() {
let config: PawConfig =
toml::from_str("[opsx]\nrole_gating = \"block\"\n").expect("parses");
assert_eq!(config.role_gating_mode(), RoleGatingMode::Block);
}
#[test]
fn role_gating_explicit_off() {
let config: PawConfig = toml::from_str("[opsx]\nrole_gating = \"off\"\n").expect("parses");
assert_eq!(config.role_gating_mode(), RoleGatingMode::Off);
}
#[test]
fn role_gating_invalid_value_is_a_parse_error() {
let err = toml::from_str::<PawConfig>("[opsx]\nrole_gating = \"loud\"\n").unwrap_err();
assert!(
err.to_string().contains("role_gating") || err.to_string().contains("variant"),
"got: {err}"
);
}
#[test]
fn role_gating_mode_round_trips_through_toml() {
let config = PawConfig {
opsx: Some(OpsxConfig {
role_gating: Some(RoleGatingMode::Block),
}),
..Default::default()
};
let serialized = toml::to_string(&config).expect("serializes");
assert!(
serialized.contains("role_gating = \"block\""),
"got: {serialized}"
);
let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
assert_eq!(reparsed.role_gating_mode(), RoleGatingMode::Block);
}
#[test]
fn opsx_section_merges_with_overlay_winning() {
let base: PawConfig =
toml::from_str("[opsx]\nrole_gating = \"warn\"\n").expect("base parses");
let overlay: PawConfig =
toml::from_str("[opsx]\nrole_gating = \"block\"\n").expect("overlay parses");
let merged = base.merged_with(&overlay);
assert_eq!(merged.role_gating_mode(), RoleGatingMode::Block);
}
#[test]
fn opsx_section_base_preserved_when_overlay_absent() {
let base: PawConfig =
toml::from_str("[opsx]\nrole_gating = \"off\"\n").expect("base parses");
let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
let merged = base.merged_with(&overlay);
assert_eq!(merged.role_gating_mode(), RoleGatingMode::Off);
}
#[test]
fn supervisor_auto_revert_defaults_false() {
let config: PawConfig = toml::from_str("[supervisor]\nenabled = true\n").expect("parses");
let sup = config.supervisor.expect("supervisor present");
assert!(!sup.auto_revert(), "auto_revert defaults to false");
}
#[test]
fn supervisor_auto_revert_explicit_true() {
let config: PawConfig =
toml::from_str("[supervisor]\nenabled = true\nauto_revert = true\n").expect("parses");
let sup = config.supervisor.expect("supervisor present");
assert!(sup.auto_revert());
}
#[test]
fn tell_config_defaults_when_table_absent() {
let config: PawConfig = toml::from_str("[supervisor]\nenabled = true\n").expect("parses");
let sup = config.supervisor.expect("supervisor present");
assert_eq!(sup.tell.mode, TellMode::Feedback);
assert_eq!(sup.tell.inventory_max_age_seconds, 60);
assert!(sup.tell.is_default());
}
#[test]
fn tell_config_explicit_feedback_loads() {
let config: PawConfig = toml::from_str(
"[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"feedback\"\n",
)
.expect("parses");
let sup = config.supervisor.expect("supervisor present");
assert_eq!(sup.tell.mode, TellMode::Feedback);
assert_eq!(sup.tell.inventory_max_age_seconds, 60);
}
#[test]
fn tell_config_explicit_send_keys_loads() {
let config: PawConfig = toml::from_str(
"[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"send-keys\"\ninventory_max_age_seconds = 15\n",
)
.expect("parses");
let sup = config.supervisor.expect("supervisor present");
assert_eq!(sup.tell.mode, TellMode::SendKeys);
assert_eq!(sup.tell.inventory_max_age_seconds, 15);
assert!(!sup.tell.is_default());
}
#[test]
fn tell_config_rejects_unknown_mode() {
let err = toml::from_str::<PawConfig>(
"[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"shout\"\n",
)
.unwrap_err();
assert!(
err.to_string().contains("shout") || err.to_string().contains("mode"),
"unknown mode should be a parse error; got {err}"
);
}
#[test]
fn tell_config_all_default_table_round_trips_without_emitting_tell() {
let sup = SupervisorConfig {
enabled: true,
..SupervisorConfig::default()
};
let config = PawConfig {
supervisor: Some(sup),
..PawConfig::default()
};
let serialized = toml::to_string_pretty(&config).expect("serializes");
assert!(
!serialized.contains("[supervisor.tell]"),
"all-default tell table must be omitted; got:\n{serialized}"
);
let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
assert_eq!(config, reparsed);
}
#[test]
fn mcp_name_parses_to_some() {
let config: PawConfig = toml::from_str("[mcp]\nname = \"my-project\"\n").expect("parses");
assert_eq!(config.mcp.name, Some("my-project".to_string()));
assert_eq!(config.mcp_server_name(), "my-project");
}
#[test]
fn mcp_section_absent_defaults_to_none() {
let config: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("parses");
assert_eq!(config.mcp, McpConfig::default());
assert!(config.mcp.name.is_none());
assert_eq!(config.mcp_server_name(), "git-paw");
}
#[test]
fn pre_existing_config_without_mcp_loads() {
let prior = "default_cli = \"claude\"\nmouse = true\n\n[broker]\nenabled = true\nport = 9119\n\n[supervisor]\nenabled = true\n";
let config: PawConfig = toml::from_str(prior).expect("prior config must still parse");
assert_eq!(config.mcp, McpConfig::default());
}
#[test]
fn mcp_config_round_trips_through_toml() {
let config = PawConfig {
mcp: McpConfig {
name: Some("my-project".to_string()),
},
..PawConfig::default()
};
let serialized = toml::to_string(&config).expect("serializes");
let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
assert_eq!(reparsed.mcp, config.mcp);
}
#[test]
fn mcp_default_omits_name_on_serialize() {
let config = PawConfig::default();
let serialized = toml::to_string_pretty(&config).expect("serializes");
assert!(
!serialized.contains("name ="),
"default [mcp] must not emit a name; got:\n{serialized}"
);
let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
assert_eq!(config, reparsed);
}
#[test]
fn mcp_overlay_name_wins_in_merge() {
let base: PawConfig = toml::from_str("[mcp]\nname = \"global-name\"\n").expect("base");
let overlay: PawConfig = toml::from_str("[mcp]\nname = \"repo-name\"\n").expect("overlay");
let merged = base.merged_with(&overlay);
assert_eq!(merged.mcp.name, Some("repo-name".to_string()));
}
#[test]
fn mcp_base_name_preserved_when_overlay_absent() {
let base: PawConfig = toml::from_str("[mcp]\nname = \"global-name\"\n").expect("base");
let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
let merged = base.merged_with(&overlay);
assert_eq!(merged.mcp.name, Some("global-name".to_string()));
}
}