use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::PawError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CustomCli {
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Preset {
pub branches: Vec<String>,
pub cli: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SpecsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
pub spec_type: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct LoggingConfig {
#[serde(default)]
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BrokerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "BrokerConfig::default_port")]
pub port: u16,
#[serde(default = "BrokerConfig::default_bind")]
pub bind: String,
}
impl Default for BrokerConfig {
fn default() -> Self {
Self {
enabled: false,
port: 9119,
bind: "127.0.0.1".to_string(),
}
}
}
impl BrokerConfig {
pub fn url(&self) -> String {
format!("http://{}:{}", self.bind, self.port)
}
fn default_port() -> u16 {
9119
}
fn default_bind() -> String {
"127.0.0.1".to_string()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PawConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_cli: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_spec_cli: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mouse: Option<bool>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub clis: HashMap<String, CustomCli>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub presets: HashMap<String, Preset>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub specs: Option<SpecsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logging: Option<LoggingConfig>,
#[serde(default)]
pub broker: BrokerConfig,
}
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()),
broker: if overlay.broker == BrokerConfig::default() {
self.broker.clone()
} else {
overlay.broker.clone()
},
}
}
pub fn get_preset(&self, name: &str) -> Option<&Preset> {
self.presets.get(name)
}
}
pub fn global_config_path() -> Result<PathBuf, PawError> {
crate::dirs::config_dir()
.map(|d| d.join("git-paw").join("config.toml"))
.ok_or_else(|| PawError::ConfigError("could not determine config directory".into()))
}
pub fn repo_config_path(repo_root: &Path) -> PathBuf {
repo_root.join(".git-paw").join("config.toml")
}
fn load_config_file(path: &Path) -> Result<Option<PawConfig>, PawError> {
match fs::read_to_string(path) {
Ok(contents) => {
let config: PawConfig = toml::from_str(&contents)
.map_err(|e| PawError::ConfigError(format!("{}: {e}", path.display())))?;
Ok(Some(config))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(PawError::ConfigError(format!("{}: {e}", path.display()))),
}
}
pub fn load_repo_config(repo_root: &Path) -> Result<PawConfig, PawError> {
Ok(load_config_file(&repo_config_path(repo_root))?.unwrap_or_default())
}
pub fn load_config(repo_root: &Path) -> Result<PawConfig, PawError> {
let global_path = global_config_path()?;
load_config_from(&global_path, repo_root)
}
pub fn load_config_from(global_path: &Path, repo_root: &Path) -> Result<PawConfig, PawError> {
let global = load_config_file(global_path)?.unwrap_or_default();
let repo = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
Ok(global.merged_with(&repo))
}
pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
save_config_to(&repo_config_path(repo_root), config)
}
fn save_config_to(path: &Path, config: &PawConfig) -> Result<(), PawError> {
let dir = path
.parent()
.ok_or_else(|| PawError::ConfigError("invalid config path".into()))?;
fs::create_dir_all(dir)
.map_err(|e| PawError::ConfigError(format!("create config dir: {e}")))?;
let contents =
toml::to_string_pretty(config).map_err(|e| PawError::ConfigError(e.to_string()))?;
let tmp = path.with_extension("toml.tmp");
fs::write(&tmp, &contents)
.map_err(|e| PawError::ConfigError(format!("write temp config: {e}")))?;
fs::rename(&tmp, path).map_err(|e| PawError::ConfigError(format!("rename config: {e}")))?;
Ok(())
}
pub fn add_custom_cli(
name: &str,
command: &str,
display_name: Option<&str>,
) -> Result<(), PawError> {
add_custom_cli_to(&global_config_path()?, name, command, display_name)
}
pub fn add_custom_cli_to(
config_path: &Path,
name: &str,
command: &str,
display_name: Option<&str>,
) -> Result<(), PawError> {
let resolved_command = if Path::new(command).is_absolute() {
command.to_string()
} else {
which::which(command)
.map_err(|_| PawError::ConfigError(format!("command '{command}' not found on PATH")))?
.to_string_lossy()
.into_owned()
};
let mut config = load_config_file(config_path)?.unwrap_or_default();
config.clis.insert(
name.to_string(),
CustomCli {
command: resolved_command,
display_name: display_name.map(String::from),
},
);
save_config_to(config_path, &config)
}
pub fn generate_default_config() -> String {
r#"# git-paw configuration
# See https://github.com/bearicorn/git-paw for documentation.
# Pre-select a CLI in the interactive picker (user can still change).
# Omit to show the full picker with no default.
# default_cli = ""
# Enable tmux mouse mode for sessions (default: true).
# mouse = true
# Bypass the CLI picker entirely for --from-specs mode.
# Omit to prompt or use per-spec paw_cli fields.
# default_spec_cli = ""
# Prefix for spec-derived branch names (default: "spec/").
# branch_prefix = "spec/"
# 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"
# Custom CLI definitions.
# [clis.my-agent]
# command = "/usr/local/bin/my-agent"
# display_name = "My Agent"
# Named presets for quick launches.
# [presets.my-preset]
# branches = ["feat/api", "fix/db"]
# cli = ""
"#
.to_string()
}
pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
remove_custom_cli_from(&global_config_path()?, name)
}
pub fn remove_custom_cli_from(config_path: &Path, name: &str) -> Result<(), PawError> {
let mut config = load_config_file(config_path)?.unwrap_or_default();
if config.clis.remove(name).is_none() {
return Err(PawError::CliNotFound(name.to_string()));
}
save_config_to(config_path, &config)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_file(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
#[test]
fn parses_config_with_all_fields() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(
&path,
r#"
default_cli = "claude"
mouse = false
default_spec_cli = "gemini"
branch_prefix = "spec/"
[clis.my-agent]
command = "/usr/local/bin/my-agent"
display_name = "My Agent"
[clis.local-llm]
command = "ollama-code"
[presets.backend]
branches = ["feature/api", "fix/db"]
cli = "claude"
[specs]
dir = "my-specs"
type = "openspec"
[logging]
enabled = true
"#,
);
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(config.default_cli.as_deref(), Some("claude"));
assert_eq!(config.mouse, Some(false));
assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
assert_eq!(config.clis.len(), 2);
assert_eq!(
config.clis["my-agent"].display_name.as_deref(),
Some("My Agent")
);
assert_eq!(config.clis["local-llm"].command, "ollama-code");
assert_eq!(config.presets["backend"].cli, "claude");
assert_eq!(
config.presets["backend"].branches,
vec!["feature/api", "fix/db"]
);
let specs = config.specs.unwrap();
assert_eq!(specs.dir.as_deref(), Some("my-specs"));
assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
let logging = config.logging.unwrap();
assert!(logging.enabled);
}
#[test]
fn all_fields_are_optional() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "default_cli = \"gemini\"\n");
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(config.default_cli.as_deref(), Some("gemini"));
assert_eq!(config.mouse, None);
assert!(config.clis.is_empty());
assert!(config.presets.is_empty());
}
#[test]
fn returns_defaults_when_no_files_exist() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("nonexistent").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.default_cli, None);
assert_eq!(config.mouse, None);
assert!(config.clis.is_empty());
assert!(config.presets.is_empty());
}
#[test]
fn reports_error_for_invalid_toml() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("bad.toml");
write_file(&path, "this is not [valid toml");
let err = load_config_file(&path).unwrap_err();
assert!(err.to_string().contains("bad.toml"));
}
#[test]
fn repo_config_overrides_global_scalars() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "default_cli = \"claude\"\nmouse = true\n");
write_file(
&repo_config_path(&repo_root),
"default_cli = \"gemini\"\n", );
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.default_cli.as_deref(), Some("gemini")); assert_eq!(config.mouse, Some(true)); }
#[test]
fn repo_config_merges_cli_maps() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "[clis.agent-a]\ncommand = \"/bin/a\"\n");
write_file(
&repo_config_path(&repo_root),
"[clis.agent-b]\ncommand = \"/bin/b\"\n",
);
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.clis.len(), 2);
assert!(config.clis.contains_key("agent-a"));
assert!(config.clis.contains_key("agent-b"));
}
#[test]
fn repo_cli_overrides_global_cli_with_same_name() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "[clis.my-agent]\ncommand = \"/old/path\"\n");
write_file(
&repo_config_path(&repo_root),
"[clis.my-agent]\ncommand = \"/new/path\"\ndisplay_name = \"Overridden\"\n",
);
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.clis["my-agent"].command, "/new/path");
assert_eq!(
config.clis["my-agent"].display_name.as_deref(),
Some("Overridden")
);
}
#[test]
fn load_config_from_reads_global_file_when_no_repo() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "default_cli = \"claude\"\nmouse = false\n");
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.default_cli.as_deref(), Some("claude"));
assert_eq!(config.mouse, Some(false));
}
#[test]
fn load_config_from_reads_repo_file_when_no_global() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("nonexistent").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&repo_config_path(&repo_root), "default_cli = \"codex\"\n");
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.default_cli.as_deref(), Some("codex"));
}
#[test]
fn preset_accessible_by_name() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(
&repo_config_path(&repo_root),
"[presets.backend]\nbranches = [\"feat/api\", \"fix/db\"]\ncli = \"claude\"\n",
);
let config = load_config_from(&global_path, &repo_root).unwrap();
let preset = config.get_preset("backend").unwrap();
assert_eq!(preset.cli, "claude");
assert_eq!(preset.branches, vec!["feat/api", "fix/db"]);
}
#[test]
fn preset_returns_none_when_not_in_config() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("config.toml");
write_file(&global_path, "default_cli = \"claude\"\n");
let config = load_config_file(&global_path).unwrap().unwrap();
assert!(config.get_preset("nonexistent").is_none());
}
#[test]
fn add_cli_writes_to_config_file() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("git-paw").join("config.toml");
add_custom_cli_to(
&config_path,
"my-agent",
"/usr/local/bin/my-agent",
Some("My Agent"),
)
.unwrap();
let config = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(config.clis.len(), 1);
assert_eq!(config.clis["my-agent"].command, "/usr/local/bin/my-agent");
assert_eq!(
config.clis["my-agent"].display_name.as_deref(),
Some("My Agent")
);
}
#[test]
fn add_cli_preserves_existing_entries() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("git-paw").join("config.toml");
add_custom_cli_to(&config_path, "first", "/bin/first", None).unwrap();
add_custom_cli_to(&config_path, "second", "/bin/second", None).unwrap();
let config = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(config.clis.len(), 2);
assert!(config.clis.contains_key("first"));
assert!(config.clis.contains_key("second"));
}
#[test]
fn add_cli_errors_when_command_not_on_path() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let err = add_custom_cli_to(&config_path, "bad", "surely-nonexistent-binary-xyz", None)
.unwrap_err();
assert!(err.to_string().contains("not found on PATH"));
}
#[test]
fn remove_cli_deletes_entry_from_config_file() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("git-paw").join("config.toml");
add_custom_cli_to(&config_path, "keep-me", "/bin/keep", None).unwrap();
add_custom_cli_to(&config_path, "remove-me", "/bin/remove", None).unwrap();
remove_custom_cli_from(&config_path, "remove-me").unwrap();
let config = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(config.clis.len(), 1);
assert!(config.clis.contains_key("keep-me"));
assert!(!config.clis.contains_key("remove-me"));
}
#[test]
fn remove_nonexistent_cli_returns_cli_not_found_error() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
write_file(&config_path, "");
let err = remove_custom_cli_from(&config_path, "nonexistent").unwrap_err();
match err {
PawError::CliNotFound(name) => assert_eq!(name, "nonexistent"),
other => panic!("expected CliNotFound, got: {other}"),
}
}
#[test]
fn remove_cli_from_empty_config_returns_error() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let err = remove_custom_cli_from(&config_path, "ghost").unwrap_err();
match err {
PawError::CliNotFound(name) => assert_eq!(name, "ghost"),
other => panic!("expected CliNotFound, got: {other}"),
}
}
#[test]
fn parses_default_spec_cli_when_present() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "default_spec_cli = \"claude\"\n");
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
}
#[test]
fn default_spec_cli_defaults_to_none() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
write_file(&path, "default_cli = \"claude\"\n");
let config = load_config_file(&path).unwrap().unwrap();
assert_eq!(config.default_spec_cli, None);
}
#[test]
fn repo_overrides_global_default_spec_cli() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "default_spec_cli = \"claude\"\n");
write_file(
&repo_config_path(&repo_root),
"default_spec_cli = \"gemini\"\n",
);
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
}
#[test]
fn global_default_spec_cli_preserved_when_repo_absent() {
let tmp = TempDir::new().unwrap();
let global_path = tmp.path().join("global").join("config.toml");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
write_file(&global_path, "default_spec_cli = \"claude\"\n");
let config = load_config_from(&global_path, &repo_root).unwrap();
assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
}
#[test]
fn config_survives_save_and_load() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let original = PawConfig {
default_cli: Some("claude".into()),
default_spec_cli: None,
branch_prefix: None,
mouse: Some(true),
clis: HashMap::from([(
"test".into(),
CustomCli {
command: "/bin/test".into(),
display_name: Some("Test CLI".into()),
},
)]),
presets: HashMap::from([(
"dev".into(),
Preset {
branches: vec!["main".into()],
cli: "claude".into(),
},
)]),
specs: None,
logging: None,
broker: BrokerConfig::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(),
};
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 broker_config_round_trip() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let original = PawConfig {
broker: BrokerConfig {
enabled: true,
port: 9200,
bind: "127.0.0.1".to_string(),
},
..Default::default()
};
save_config_to(&config_path, &original).unwrap();
let loaded = load_config_file(&config_path).unwrap().unwrap();
assert_eq!(loaded.broker.enabled, original.broker.enabled);
assert_eq!(loaded.broker.port, original.broker.port);
assert_eq!(loaded.broker.bind, original.broker.bind);
}
}