use crate::color::ColorMode;
use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum SortKey {
#[default]
Name,
Size,
Mtime,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
#[default]
Tree,
Json,
Html,
Xml,
}
#[derive(Debug, Clone)]
pub struct Config {
pub ignore_files: Vec<String>,
pub include_hidden: bool,
pub max_depth: Option<usize>,
pub output: Option<PathBuf>,
pub follow_symlinks: bool,
pub directories_only: bool,
pub use_ignore: bool,
pub color: ColorMode,
pub show_sizes: bool,
pub show_mtime: bool,
pub sort: SortKey,
pub reverse: bool,
pub include: Vec<String>,
pub exclude: Vec<String>,
pub no_report: bool,
pub show_symlink_target: bool,
pub show_permissions: bool,
pub format: OutputFormat,
}
impl Default for Config {
fn default() -> Self {
Self {
ignore_files: default_ignore_files(),
include_hidden: false,
max_depth: None,
output: None,
follow_symlinks: false,
directories_only: false,
use_ignore: true,
color: ColorMode::Auto,
show_sizes: false,
show_mtime: false,
sort: SortKey::Name,
reverse: false,
include: Vec::new(),
exclude: Vec::new(),
no_report: false,
show_symlink_target: false,
show_permissions: false,
format: OutputFormat::Tree,
}
}
}
pub fn default_ignore_files() -> Vec<String> {
vec![
".gitignore".into(),
".ignore".into(),
".dockerignore".into(),
".npmignore".into(),
".eslintignore".into(),
".prettierignore".into(),
]
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialConfig {
pub ignore_files: Option<Vec<String>>,
pub include_hidden: Option<bool>,
pub max_depth: Option<usize>,
pub output: Option<PathBuf>,
pub follow_symlinks: Option<bool>,
pub directories_only: Option<bool>,
pub use_ignore: Option<bool>,
pub color: Option<ColorMode>,
pub show_sizes: Option<bool>,
pub show_mtime: Option<bool>,
pub sort: Option<SortKey>,
pub reverse: Option<bool>,
pub include: Option<Vec<String>>,
pub exclude: Option<Vec<String>>,
pub no_report: Option<bool>,
pub show_symlink_target: Option<bool>,
pub show_permissions: Option<bool>,
pub format: Option<OutputFormat>,
}
impl PartialConfig {
fn load(path: &Path) -> Result<Self> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("reading config {}", path.display()))?;
if contents.trim().is_empty() {
return Ok(Self::default());
}
serde_json::from_str(&contents)
.with_context(|| format!("parsing config {}", path.display()))
}
fn apply(self, cfg: &mut Config) {
if let Some(v) = self.ignore_files {
cfg.ignore_files = v;
}
if let Some(v) = self.include_hidden {
cfg.include_hidden = v;
}
if let Some(v) = self.max_depth {
cfg.max_depth = Some(v);
}
if let Some(v) = self.output {
cfg.output = Some(v);
}
if let Some(v) = self.follow_symlinks {
cfg.follow_symlinks = v;
}
if let Some(v) = self.directories_only {
cfg.directories_only = v;
}
if let Some(v) = self.use_ignore {
cfg.use_ignore = v;
}
if let Some(v) = self.color {
cfg.color = v;
}
if let Some(v) = self.show_sizes {
cfg.show_sizes = v;
}
if let Some(v) = self.show_mtime {
cfg.show_mtime = v;
}
if let Some(v) = self.sort {
cfg.sort = v;
}
if let Some(v) = self.reverse {
cfg.reverse = v;
}
if let Some(v) = self.include {
cfg.include = v;
}
if let Some(v) = self.exclude {
cfg.exclude = v;
}
if let Some(v) = self.no_report {
cfg.no_report = v;
}
if let Some(v) = self.show_symlink_target {
cfg.show_symlink_target = v;
}
if let Some(v) = self.show_permissions {
cfg.show_permissions = v;
}
if let Some(v) = self.format {
cfg.format = v;
}
}
}
fn find_local_config(start: &Path, home: Option<&Path>) -> Option<PathBuf> {
let canonical = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
let mut current: Option<&Path> = Some(&canonical);
while let Some(dir) = current {
if home == Some(dir) {
break;
}
let json_candidate = dir.join(".bush.json");
if json_candidate.is_file() {
return Some(json_candidate);
}
let candidate = dir.join(".bush");
if candidate.is_file() {
return Some(candidate);
}
current = dir.parent();
}
None
}
pub fn load_layered(
explicit: Option<&Path>,
skip_config_files: bool,
target_dir: &Path,
home: Option<&Path>,
xdg_config: Option<&Path>,
) -> Result<Config> {
let mut cfg = Config::default();
if let Some(path) = explicit {
PartialConfig::load(path)?.apply(&mut cfg);
return Ok(cfg);
}
if skip_config_files {
return Ok(cfg);
}
if let Some(h) = home {
let home_cfg = h.join(".bush");
if home_cfg.is_file() {
PartialConfig::load(&home_cfg)?.apply(&mut cfg);
}
}
if let Some(h) = home {
let xdg_default = h.join(".config").join("bush").join("config.json");
if xdg_default.is_file() {
PartialConfig::load(&xdg_default)?.apply(&mut cfg);
}
}
if let Some(xdg) = xdg_config {
let xdg_cfg = xdg.join("bush").join("config.json");
if xdg_cfg.is_file() {
PartialConfig::load(&xdg_cfg)?.apply(&mut cfg);
}
}
if let Some(local) = find_local_config(target_dir, home) {
PartialConfig::load(&local)?.apply(&mut cfg);
}
Ok(cfg)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn canon(p: &Path) -> PathBuf {
p.canonicalize().unwrap_or_else(|_| p.to_path_buf())
}
#[test]
fn default_config_has_six_ignore_files() {
let cfg = Config::default();
assert_eq!(cfg.ignore_files.len(), 6);
assert!(cfg.ignore_files.contains(&".gitignore".to_string()));
assert!(cfg.ignore_files.contains(&".dockerignore".to_string()));
assert!(cfg.ignore_files.contains(&".npmignore".to_string()));
assert!(cfg.ignore_files.contains(&".eslintignore".to_string()));
assert!(cfg.ignore_files.contains(&".prettierignore".to_string()));
assert!(cfg.ignore_files.contains(&".ignore".to_string()));
assert!(!cfg.include_hidden);
assert!(cfg.use_ignore);
assert!(cfg.max_depth.is_none());
assert!(cfg.output.is_none());
}
#[test]
fn partial_config_default_is_all_none() {
let pc = PartialConfig::default();
assert!(pc.ignore_files.is_none());
assert!(pc.include_hidden.is_none());
assert!(pc.max_depth.is_none());
assert!(pc.output.is_none());
assert!(pc.follow_symlinks.is_none());
assert!(pc.directories_only.is_none());
assert!(pc.use_ignore.is_none());
}
#[test]
fn apply_replaces_only_specified_fields() {
let mut cfg = Config::default();
let original_files = cfg.ignore_files.clone();
let pc = PartialConfig {
max_depth: Some(3),
..Default::default()
};
pc.apply(&mut cfg);
assert_eq!(cfg.max_depth, Some(3));
assert_eq!(cfg.ignore_files, original_files);
assert!(!cfg.include_hidden);
}
#[test]
fn apply_replaces_ignore_files_when_specified() {
let mut cfg = Config::default();
let pc = PartialConfig {
ignore_files: Some(vec![".only".into()]),
..Default::default()
};
pc.apply(&mut cfg);
assert_eq!(cfg.ignore_files, vec![".only".to_string()]);
}
#[test]
fn apply_replaces_ignore_files_with_empty_list() {
let mut cfg = Config::default();
let pc = PartialConfig {
ignore_files: Some(vec![]),
..Default::default()
};
pc.apply(&mut cfg);
assert!(cfg.ignore_files.is_empty());
}
#[test]
fn apply_replaces_all_fields_when_all_specified() {
let mut cfg = Config::default();
let pc = PartialConfig {
ignore_files: Some(vec![".x".into()]),
include_hidden: Some(true),
max_depth: Some(7),
output: Some(PathBuf::from("out.txt")),
follow_symlinks: Some(true),
directories_only: Some(true),
use_ignore: Some(false),
color: Some(ColorMode::Always),
show_sizes: Some(true),
show_mtime: Some(true),
sort: Some(SortKey::Size),
reverse: Some(true),
include: Some(vec!["*.rs".into()]),
exclude: Some(vec!["target".into()]),
no_report: Some(true),
show_symlink_target: Some(true),
show_permissions: Some(true),
format: Some(OutputFormat::Json),
};
pc.apply(&mut cfg);
assert_eq!(cfg.ignore_files, vec![".x".to_string()]);
assert!(cfg.include_hidden);
assert_eq!(cfg.max_depth, Some(7));
assert_eq!(cfg.output, Some(PathBuf::from("out.txt")));
assert!(cfg.follow_symlinks);
assert!(cfg.directories_only);
assert!(!cfg.use_ignore);
}
#[test]
fn load_valid_json() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".bush");
std::fs::write(&path, r#"{"max_depth": 4, "include_hidden": true}"#).unwrap();
let pc = PartialConfig::load(&path).unwrap();
assert_eq!(pc.max_depth, Some(4));
assert_eq!(pc.include_hidden, Some(true));
assert!(pc.ignore_files.is_none());
}
#[test]
fn load_empty_json_object() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".bush");
std::fs::write(&path, "{}").unwrap();
let pc = PartialConfig::load(&path).unwrap();
assert!(pc.max_depth.is_none());
}
#[test]
fn load_truly_empty_file_returns_default() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".bush");
std::fs::write(&path, "").unwrap();
let pc = PartialConfig::load(&path).unwrap();
assert!(pc.ignore_files.is_none());
assert!(pc.include_hidden.is_none());
}
#[test]
fn load_whitespace_only_file_returns_default() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".bush");
std::fs::write(&path, " \n \t\n").unwrap();
let pc = PartialConfig::load(&path).unwrap();
assert!(pc.ignore_files.is_none());
}
#[test]
fn load_invalid_json_errors() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".bush");
std::fs::write(&path, "{ not valid json").unwrap();
let err = PartialConfig::load(&path).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("parsing config"), "got: {msg}");
}
#[test]
fn load_unknown_field_errors() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".bush");
std::fs::write(&path, r#"{"typo_field": 1}"#).unwrap();
let err = PartialConfig::load(&path).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("unknown field"), "got: {msg}");
}
#[test]
fn load_nonexistent_file_errors() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nope.json");
let err = PartialConfig::load(&path).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("reading config"), "got: {msg}");
}
#[test]
fn find_local_config_in_start_dir() {
let tmp = TempDir::new().unwrap();
let bush_path = tmp.path().join(".bush");
std::fs::write(&bush_path, "{}").unwrap();
let found = find_local_config(tmp.path(), None).unwrap();
assert_eq!(canon(&found), canon(&bush_path));
}
#[test]
fn find_local_config_walks_up() {
let tmp = TempDir::new().unwrap();
let bush_path = tmp.path().join(".bush");
std::fs::write(&bush_path, "{}").unwrap();
let nested = tmp.path().join("a").join("b").join("c");
std::fs::create_dir_all(&nested).unwrap();
let found = find_local_config(&nested, None).unwrap();
assert_eq!(canon(&found), canon(&bush_path));
}
#[test]
fn find_local_config_returns_none_when_missing() {
let tmp = TempDir::new().unwrap();
let found = find_local_config(tmp.path(), None);
assert!(found.is_none());
}
#[test]
fn find_local_config_stops_at_home() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("home");
let project = home.join("project");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(home.join(".bush"), "{}").unwrap();
let found = find_local_config(&project, Some(&canon(&home)));
assert!(
found.is_none(),
"should not surface ~/.bush as a local config (got {found:?})"
);
}
#[test]
fn find_local_config_prefers_closer_ancestor() {
let tmp = TempDir::new().unwrap();
let outer_bush = tmp.path().join(".bush");
let inner = tmp.path().join("inner");
let inner_bush = inner.join(".bush");
std::fs::create_dir_all(&inner).unwrap();
std::fs::write(&outer_bush, r#"{"max_depth": 1}"#).unwrap();
std::fs::write(&inner_bush, r#"{"max_depth": 9}"#).unwrap();
let nested = inner.join("deeper");
std::fs::create_dir_all(&nested).unwrap();
let found = find_local_config(&nested, None).unwrap();
assert_eq!(canon(&found), canon(&inner_bush));
}
#[test]
fn load_layered_no_configs_returns_defaults() {
let tmp = TempDir::new().unwrap();
let cfg = load_layered(None, false, tmp.path(), None, None).unwrap();
let default = Config::default();
assert_eq!(cfg.ignore_files, default.ignore_files);
assert_eq!(cfg.include_hidden, default.include_hidden);
assert_eq!(cfg.max_depth, default.max_depth);
assert_eq!(cfg.use_ignore, default.use_ignore);
}
#[test]
fn load_layered_skip_config_files() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".bush"), r#"{"include_hidden": true}"#).unwrap();
let cfg = load_layered(None, true, tmp.path(), None, None).unwrap();
assert!(!cfg.include_hidden);
}
#[test]
fn load_layered_explicit_bypasses_discovery() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".bush"), r#"{"max_depth": 1}"#).unwrap();
let explicit = tmp.path().join("custom.json");
std::fs::write(&explicit, r#"{"max_depth": 9}"#).unwrap();
let cfg = load_layered(Some(&explicit), false, tmp.path(), None, None).unwrap();
assert_eq!(cfg.max_depth, Some(9));
}
#[test]
fn load_layered_home_only() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("home");
let project = tmp.path().join("project");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(home.join(".bush"), r#"{"max_depth": 7}"#).unwrap();
let cfg = load_layered(None, false, &project, Some(&canon(&home)), None).unwrap();
assert_eq!(cfg.max_depth, Some(7));
}
#[test]
fn load_layered_local_only() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("home");
let project = tmp.path().join("project");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(project.join(".bush"), r#"{"max_depth": 4}"#).unwrap();
let cfg = load_layered(None, false, &project, Some(&canon(&home)), None).unwrap();
assert_eq!(cfg.max_depth, Some(4));
}
#[test]
fn load_layered_local_overrides_home() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("home");
let project = tmp.path().join("project");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(
home.join(".bush"),
r#"{"max_depth": 5, "include_hidden": true}"#,
)
.unwrap();
std::fs::write(project.join(".bush"), r#"{"max_depth": 9}"#).unwrap();
let cfg = load_layered(None, false, &project, Some(&canon(&home)), None).unwrap();
assert_eq!(cfg.max_depth, Some(9));
assert!(
cfg.include_hidden,
"home setting must survive when local doesn't override it"
);
}
#[test]
fn load_layered_explicit_wins_over_skip_config() {
let tmp = TempDir::new().unwrap();
let explicit = tmp.path().join("custom.json");
std::fs::write(&explicit, r#"{"max_depth": 5}"#).unwrap();
let cfg = load_layered(Some(&explicit), true, tmp.path(), None, None).unwrap();
assert_eq!(cfg.max_depth, Some(5));
}
#[test]
fn load_layered_explicit_bypasses_home_and_local() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("home");
let project = tmp.path().join("project");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(home.join(".bush"), r#"{"max_depth": 1}"#).unwrap();
std::fs::write(project.join(".bush"), r#"{"max_depth": 2}"#).unwrap();
let explicit = tmp.path().join("custom.json");
std::fs::write(&explicit, r#"{"max_depth": 9}"#).unwrap();
let cfg =
load_layered(Some(&explicit), false, &project, Some(&canon(&home)), None).unwrap();
assert_eq!(
cfg.max_depth,
Some(9),
"explicit must bypass discovery layers"
);
}
#[test]
fn load_layered_skip_config_ignores_home() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("home");
let project = tmp.path().join("project");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(home.join(".bush"), r#"{"max_depth": 3}"#).unwrap();
let cfg = load_layered(None, true, &project, Some(&canon(&home)), None).unwrap();
assert!(
cfg.max_depth.is_none(),
"home should not be loaded under --no-config"
);
}
#[test]
fn load_layered_skip_config_ignores_local() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".bush"), r#"{"max_depth": 3}"#).unwrap();
let cfg = load_layered(None, true, tmp.path(), None, None).unwrap();
assert!(
cfg.max_depth.is_none(),
"local must not be loaded under --no-config"
);
}
#[test]
fn load_layered_handles_no_home_env() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".bush"), r#"{"max_depth": 4}"#).unwrap();
let cfg = load_layered(None, false, tmp.path(), None, None).unwrap();
assert_eq!(cfg.max_depth, Some(4));
}
#[test]
fn load_layered_handles_nonexistent_home() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".bush"), r#"{"max_depth": 4}"#).unwrap();
let bogus_home = PathBuf::from("/_bush_test_no_home_xyz_42");
let cfg = load_layered(None, false, tmp.path(), Some(&bogus_home), None).unwrap();
assert_eq!(cfg.max_depth, Some(4));
}
#[test]
fn find_local_config_handles_nonexistent_start() {
let bogus = PathBuf::from("/_bush_test_does_not_exist_xyz_43/deeper");
let found = find_local_config(&bogus, None);
assert!(found.is_none());
}
#[test]
fn find_local_config_handles_filesystem_root() {
let found = find_local_config(Path::new("/"), None);
assert!(found.is_none(), "expected no .bush at /; got {found:?}");
}
#[test]
fn find_local_config_with_trailing_dot_in_home() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("home");
let project = home.join("project");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(home.join(".bush"), "{}").unwrap();
let with_dot = home.join(".");
let found = find_local_config(&project, Some(&with_dot));
assert!(
found.is_none(),
"trailing-dot home should still act as boundary"
);
}
#[test]
fn find_local_config_with_trailing_slash_in_home() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("home");
let project = home.join("project");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(home.join(".bush"), "{}").unwrap();
let with_slash = PathBuf::from(format!("{}/", home.display()));
let found = find_local_config(&project, Some(&with_slash));
assert!(
found.is_none(),
"trailing-slash home should still act as boundary"
);
}
#[test]
fn config_deserialization_rejects_negative_max_depth() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".bush");
std::fs::write(&path, r#"{"max_depth": -5}"#).unwrap();
let err = PartialConfig::load(&path).unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("parsing config") || msg.contains("invalid"),
"got: {msg}"
);
}
#[test]
fn config_deserialization_rejects_wrong_type_for_bool() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".bush");
std::fs::write(&path, r#"{"include_hidden": "yes"}"#).unwrap();
let err = PartialConfig::load(&path).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("parsing config"), "got: {msg}");
}
#[test]
fn config_deserialization_rejects_wrong_type_for_array() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".bush");
std::fs::write(&path, r#"{"ignore_files": "not_an_array"}"#).unwrap();
let err = PartialConfig::load(&path).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("parsing config"), "got: {msg}");
}
#[test]
fn config_accepts_all_seven_fields_simultaneously() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".bush");
std::fs::write(
&path,
r#"{
"ignore_files": [".foo", ".bar"],
"include_hidden": true,
"max_depth": 3,
"output": "out.txt",
"follow_symlinks": true,
"directories_only": true,
"use_ignore": false
}"#,
)
.unwrap();
let pc = PartialConfig::load(&path).unwrap();
assert_eq!(
pc.ignore_files,
Some(vec![".foo".to_string(), ".bar".to_string()])
);
assert_eq!(pc.include_hidden, Some(true));
assert_eq!(pc.max_depth, Some(3));
assert_eq!(pc.output, Some(PathBuf::from("out.txt")));
assert_eq!(pc.follow_symlinks, Some(true));
assert_eq!(pc.directories_only, Some(true));
assert_eq!(pc.use_ignore, Some(false));
}
#[test]
fn load_layered_propagates_invalid_json() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".bush"), "{ bad").unwrap();
let err = load_layered(None, false, tmp.path(), None, None).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("parsing config"), "got: {msg}");
}
#[cfg(unix)]
#[test]
fn local_bush_can_be_a_symlink() {
let tmp = TempDir::new().unwrap();
let real = tmp.path().join("real_config.json");
std::fs::write(&real, r#"{"max_depth": 4}"#).unwrap();
std::os::unix::fs::symlink(&real, tmp.path().join(".bush")).unwrap();
let cfg = load_layered(None, false, tmp.path(), None, None).unwrap();
assert_eq!(cfg.max_depth, Some(4), "symlinked .bush should be read");
}
#[test]
fn local_bush_as_directory_is_treated_as_missing() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join(".bush")).unwrap();
let cfg = load_layered(None, false, tmp.path(), None, None).unwrap();
assert_eq!(
cfg.max_depth, None,
".bush as a directory should be skipped silently"
);
assert_eq!(cfg.ignore_files, default_ignore_files());
}
#[test]
fn xdg_config_loaded_when_set() {
let tmp = TempDir::new().unwrap();
let xdg = tmp.path().join("xdg");
std::fs::create_dir_all(xdg.join("bush")).unwrap();
std::fs::write(xdg.join("bush/config.json"), r#"{"max_depth": 7}"#).unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let cfg = load_layered(None, false, &project, None, Some(&xdg)).unwrap();
assert_eq!(cfg.max_depth, Some(7));
}
#[test]
fn xdg_default_loaded_from_home_dot_config() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("home");
std::fs::create_dir_all(home.join(".config/bush")).unwrap();
std::fs::write(home.join(".config/bush/config.json"), r#"{"max_depth": 9}"#).unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let cfg = load_layered(None, false, &project, Some(&canon(&home)), None).unwrap();
assert_eq!(cfg.max_depth, Some(9));
}
#[test]
fn xdg_explicit_overrides_default() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("home");
std::fs::create_dir_all(home.join(".config/bush")).unwrap();
std::fs::write(home.join(".config/bush/config.json"), r#"{"max_depth": 1}"#).unwrap();
let xdg = tmp.path().join("xdg");
std::fs::create_dir_all(xdg.join("bush")).unwrap();
std::fs::write(xdg.join("bush/config.json"), r#"{"max_depth": 5}"#).unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let cfg = load_layered(None, false, &project, Some(&canon(&home)), Some(&xdg)).unwrap();
assert_eq!(cfg.max_depth, Some(5));
}
#[test]
fn local_overrides_xdg() {
let tmp = TempDir::new().unwrap();
let xdg = tmp.path().join("xdg");
std::fs::create_dir_all(xdg.join("bush")).unwrap();
std::fs::write(xdg.join("bush/config.json"), r#"{"max_depth": 2}"#).unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(project.join(".bush"), r#"{"max_depth": 99}"#).unwrap();
let cfg = load_layered(None, false, &project, None, Some(&xdg)).unwrap();
assert_eq!(cfg.max_depth, Some(99));
}
#[test]
fn dot_bush_json_filename_works() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".bush.json"), r#"{"max_depth": 4}"#).unwrap();
let cfg = load_layered(None, false, tmp.path(), None, None).unwrap();
assert_eq!(cfg.max_depth, Some(4));
}
#[test]
fn dot_bush_json_preferred_over_dot_bush() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".bush"), r#"{"max_depth": 1}"#).unwrap();
std::fs::write(tmp.path().join(".bush.json"), r#"{"max_depth": 9}"#).unwrap();
let cfg = load_layered(None, false, tmp.path(), None, None).unwrap();
assert_eq!(cfg.max_depth, Some(9));
}
#[test]
fn skip_config_ignores_xdg_too() {
let tmp = TempDir::new().unwrap();
let xdg = tmp.path().join("xdg");
std::fs::create_dir_all(xdg.join("bush")).unwrap();
std::fs::write(xdg.join("bush/config.json"), r#"{"max_depth": 5}"#).unwrap();
let cfg = load_layered(None, true, tmp.path(), None, Some(&xdg)).unwrap();
assert!(cfg.max_depth.is_none());
}
#[test]
fn config_with_utf8_bom_documents_behavior() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".bush");
let bom_content = "\u{FEFF}{\"max_depth\": 5}";
std::fs::write(&path, bom_content).unwrap();
match PartialConfig::load(&path) {
Ok(pc) => assert_eq!(pc.max_depth, Some(5)),
Err(e) => {
let msg = format!("{:#}", e);
assert!(msg.contains("parsing config"), "got: {msg}");
}
}
}
}