use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::num::NonZeroUsize;
use std::path::Path;
use crate::error::ConfigError;
use crate::format::OutputFormat;
#[non_exhaustive]
#[derive(Debug, Default, Clone)]
pub struct CliOverride {
pub format: Option<OutputFormat>,
pub max_clipboard_mb: Option<usize>,
pub max_depth: Option<usize>,
pub max_files: Option<usize>,
pub include_dotfiles: Option<bool>,
pub respect_gitignore: Option<bool>,
pub ignore_globs: Option<Vec<String>>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
#[serde(default = "default_false")]
pub include_dotfiles: bool,
#[serde(default = "default_true")]
pub respect_gitignore: bool,
#[serde(default)]
pub format: Option<ConfigFormat>,
#[serde(default)]
pub max_depth: Option<usize>,
#[serde(default)]
pub max_files: Option<NonZeroUsize>,
#[serde(default)]
pub max_clipboard_mb: Option<usize>,
#[serde(default)]
pub ignore_extensions: Vec<String>,
#[serde(default)]
pub ignore_directories: Vec<String>,
#[serde(default)]
pub ignore_files: Vec<String>,
#[serde(default)]
pub ignore_globs: Vec<String>,
}
impl Default for ConfigFile {
fn default() -> Self {
Self {
include_dotfiles: default_false(),
respect_gitignore: default_true(),
format: None,
max_depth: None,
max_files: None,
max_clipboard_mb: None,
ignore_extensions: Vec::new(),
ignore_directories: Vec::new(),
ignore_files: Vec::new(),
ignore_globs: Vec::new(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ConfigFormat {
Markdown,
Tree,
}
impl From<ConfigFormat> for OutputFormat {
fn from(fmt: ConfigFormat) -> Self {
match fmt {
ConfigFormat::Markdown => Self::Markdown,
ConfigFormat::Tree => Self::Tree,
}
}
}
const MAX_DEPTH_LIMIT: usize = 100;
const MAX_FILES_LIMIT: usize = 10_000_000;
const MAX_CLIPBOARD_LIMIT: usize = 1000;
impl ConfigFile {
pub fn validate(&self, cli_override: &CliOverride) -> Result<ValidatedConfig, ConfigError> {
let format = cli_override
.format
.or_else(|| self.format.map(Into::into))
.unwrap_or(OutputFormat::Markdown);
let max_depth = cli_override.max_depth.or(self.max_depth).unwrap_or(0);
let max_files = cli_override
.max_files
.or_else(|| self.max_files.map(NonZeroUsize::get))
.unwrap_or(1_000_000);
let max_clipboard_mb = cli_override
.max_clipboard_mb
.or(self.max_clipboard_mb)
.unwrap_or(100);
let include_dotfiles = cli_override
.include_dotfiles
.unwrap_or(self.include_dotfiles);
let respect_gitignore = cli_override
.respect_gitignore
.unwrap_or(self.respect_gitignore);
Self::validate_max_depth(max_depth)?;
Self::validate_max_files(max_files)?;
Self::validate_max_clipboard_mb(max_clipboard_mb)?;
let mut ignore_globs = self.ignore_globs.clone();
if let Some(cli_globs) = &cli_override.ignore_globs {
ignore_globs.extend(cli_globs.iter().cloned());
}
let patterns = crate::config::IgnorePatterns::from_config(
&self.ignore_extensions,
&self.ignore_directories,
&self.ignore_files,
&ignore_globs,
)?;
Ok(ValidatedConfig {
include_dotfiles,
respect_gitignore,
format,
max_depth,
max_files,
max_clipboard_mb,
patterns,
})
}
#[allow(clippy::missing_const_for_fn)]
fn validate_max_depth(value: usize) -> Result<(), ConfigError> {
if value > MAX_DEPTH_LIMIT {
return Err(ConfigError::InvalidMaxDepth {
value,
max: MAX_DEPTH_LIMIT,
});
}
Ok(())
}
#[allow(clippy::missing_const_for_fn)]
fn validate_max_files(value: usize) -> Result<(), ConfigError> {
if value == 0 || value > MAX_FILES_LIMIT {
return Err(ConfigError::InvalidMaxFiles {
value,
max: MAX_FILES_LIMIT,
});
}
Ok(())
}
#[allow(clippy::missing_const_for_fn)]
fn validate_max_clipboard_mb(value: usize) -> Result<(), ConfigError> {
if value > MAX_CLIPBOARD_LIMIT {
return Err(ConfigError::InvalidMaxClipboardMb {
value,
max: MAX_CLIPBOARD_LIMIT,
});
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ValidatedConfig {
include_dotfiles: bool,
respect_gitignore: bool,
format: OutputFormat,
max_depth: usize,
max_files: usize,
max_clipboard_mb: usize,
patterns: crate::config::IgnorePatterns,
}
impl ValidatedConfig {
#[cfg(test)]
#[must_use]
pub fn for_test(cli_override: &CliOverride) -> Self {
ConfigFile::default()
.validate(cli_override)
.expect("test config must validate")
}
#[must_use]
pub const fn format(&self) -> OutputFormat {
self.format
}
#[must_use]
pub const fn max_depth(&self) -> usize {
self.max_depth
}
#[must_use]
pub const fn max_files(&self) -> usize {
self.max_files
}
#[must_use]
pub const fn max_clipboard_mb(&self) -> usize {
self.max_clipboard_mb
}
#[must_use]
pub const fn include_dotfiles(&self) -> bool {
self.include_dotfiles
}
#[must_use]
pub const fn respect_gitignore(&self) -> bool {
self.respect_gitignore
}
#[must_use]
pub const fn patterns(&self) -> &crate::config::IgnorePatterns {
&self.patterns
}
}
const MAX_CONFIG_FILE_SIZE: u64 = 1_048_576;
pub fn load_config_file(config_path: &Path) -> Result<ConfigFile, ConfigError> {
let canonical = config_path.canonicalize().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ConfigError::FileNotFound {
path: config_path.to_path_buf(),
source: e,
}
} else {
ConfigError::InvalidPath {
path: config_path.to_path_buf(),
source: e,
}
}
})?;
let mut file = std::fs::File::open(&canonical).map_err(|e| ConfigError::InvalidPath {
path: config_path.to_path_buf(),
source: e,
})?;
let metadata = file.metadata().map_err(|e| ConfigError::InvalidPath {
path: config_path.to_path_buf(),
source: e,
})?;
if !metadata.is_file() {
return Err(ConfigError::NotAFile {
path: config_path.to_path_buf(),
});
}
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
let file_type = metadata.file_type();
if file_type.is_fifo() || file_type.is_socket() {
return Err(ConfigError::InvalidFileType {
path: config_path.to_path_buf(),
message: "Config file cannot be a FIFO or socket".to_string(),
});
}
}
let file_size = metadata.len();
if file_size > MAX_CONFIG_FILE_SIZE {
return Err(ConfigError::FileTooLarge {
path: config_path.to_path_buf(),
size: file_size,
max_size: MAX_CONFIG_FILE_SIZE,
});
}
let capacity = usize::try_from(file_size).unwrap_or(0);
let mut contents = String::with_capacity(capacity);
let _ = file
.read_to_string(&mut contents)
.map_err(|e| ConfigError::InvalidPath {
path: config_path.to_path_buf(),
source: e,
})?;
serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseError {
path: config_path.to_path_buf(),
source: Box::new(figment::Error::from(e.to_string())),
})
}
const fn default_false() -> bool {
false
}
const fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_config_format_to_output_format() {
assert_eq!(
OutputFormat::from(ConfigFormat::Markdown),
OutputFormat::Markdown
);
assert_eq!(OutputFormat::from(ConfigFormat::Tree), OutputFormat::Tree);
}
#[test]
fn test_load_config_file_not_found() {
let result = load_config_file(Path::new("/nonexistent/config.yaml"));
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ConfigError::FileNotFound { .. }
));
}
#[test]
fn test_load_config_file_invalid_yaml() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("invalid.yaml");
fs::write(&config_path, "invalid: yaml: content: :").unwrap();
let result = load_config_file(&config_path);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ConfigError::ParseError { .. }
));
}
#[test]
fn test_load_config_file_rejects_unknown_fields() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("typo.yaml");
fs::write(&config_path, "include_dotfile: true\n").unwrap();
let result = load_config_file(&config_path);
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ConfigError::ParseError { .. }),
"unknown fields in config YAML should be rejected"
);
}
#[test]
fn test_load_config_file_accepts_known_fields() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("valid.yaml");
fs::write(&config_path, "include_dotfiles: true\nmax_depth: 5\n").unwrap();
let config = load_config_file(&config_path).unwrap();
assert!(config.include_dotfiles);
assert_eq!(config.max_depth, Some(5));
}
#[test]
fn test_load_config_file_rejects_directory() {
let temp = TempDir::new().unwrap();
let result = load_config_file(temp.path());
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ConfigError::NotAFile { .. }),
"a directory path should be rejected with NotAFile"
);
}
#[test]
fn test_load_config_file_rejects_too_large() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("huge.yaml");
let size = usize::try_from(MAX_CONFIG_FILE_SIZE)
.expect("MAX_CONFIG_FILE_SIZE must fit in usize")
+ 1;
let contents = "a".repeat(size);
fs::write(&config_path, contents).unwrap();
let result = load_config_file(&config_path);
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ConfigError::FileTooLarge { .. }),
"oversized config file should be rejected with FileTooLarge"
);
}
fn round_trip_config(config: &ConfigFile) -> ConfigFile {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("round_trip.yaml");
let yaml = serde_yaml::to_string(config).expect("ConfigFile must serialize");
fs::write(&config_path, &yaml).expect("write must succeed");
load_config_file(&config_path).expect("round-trip load must succeed")
}
proptest! {
#[test]
fn test_max_depth_boundary(value in 0_usize..=200) {
let config = ConfigFile {
max_depth: Some(value),
..Default::default()
};
let result = config.validate(&CliOverride::default());
if value <= MAX_DEPTH_LIMIT {
let v = result.expect("should validate");
prop_assert_eq!(v.max_depth(), value);
} else {
let err = result.expect_err("should reject");
prop_assert!(
matches!(err, ConfigError::InvalidMaxDepth { .. }),
"unexpected error variant: {err:?}"
);
}
}
#[test]
fn test_max_files_boundary(value in 0_usize..=20_000_000) {
let cli_override = CliOverride {
max_files: Some(value),
..Default::default()
};
let config = ConfigFile::default();
let result = config.validate(&cli_override);
if (1..=MAX_FILES_LIMIT).contains(&value) {
let v = result.expect("should validate");
prop_assert_eq!(v.max_files(), value);
} else {
let err = result.expect_err("should reject");
prop_assert!(
matches!(err, ConfigError::InvalidMaxFiles { .. }),
"unexpected error variant: {err:?}"
);
}
}
#[test]
fn test_max_clipboard_mb_boundary(value in 0_usize..=2000) {
let config = ConfigFile {
max_clipboard_mb: Some(value),
..Default::default()
};
let result = config.validate(&CliOverride::default());
if value <= MAX_CLIPBOARD_LIMIT {
let v = result.expect("should validate");
prop_assert_eq!(v.max_clipboard_mb(), value);
} else {
let err = result.expect_err("should reject");
prop_assert!(
matches!(err, ConfigError::InvalidMaxClipboardMb { .. }),
"unexpected error variant: {err:?}"
);
}
}
#[test]
fn test_cli_override_precedence(
cfg_format in prop::option::of(prop::sample::select(vec![
ConfigFormat::Markdown,
ConfigFormat::Tree,
])),
cli_format in prop::option::of(prop::sample::select(vec![
OutputFormat::Markdown,
OutputFormat::Tree,
])),
cfg_depth in prop::option::of(0_usize..=100),
cli_depth in prop::option::of(0_usize..=100),
cfg_dotfiles in proptest::bool::ANY,
cli_dotfiles in prop::option::of(proptest::bool::ANY),
cfg_gitignore in proptest::bool::ANY,
cli_gitignore in prop::option::of(proptest::bool::ANY),
) {
let config = ConfigFile {
format: cfg_format,
max_depth: cfg_depth,
include_dotfiles: cfg_dotfiles,
respect_gitignore: cfg_gitignore,
..Default::default()
};
let cli_override = CliOverride {
format: cli_format,
max_depth: cli_depth,
include_dotfiles: cli_dotfiles,
respect_gitignore: cli_gitignore,
..Default::default()
};
let result = config.validate(&cli_override).expect("should validate");
let expected_format = cli_format
.or_else(|| cfg_format.map(Into::into))
.unwrap_or(OutputFormat::Markdown);
prop_assert_eq!(result.format(), expected_format);
let expected_depth = cli_depth.or(cfg_depth).unwrap_or(0);
prop_assert_eq!(result.max_depth(), expected_depth);
let expected_dotfiles = cli_dotfiles.unwrap_or(cfg_dotfiles);
prop_assert_eq!(result.include_dotfiles(), expected_dotfiles);
let expected_gitignore = cli_gitignore.unwrap_or(cfg_gitignore);
prop_assert_eq!(result.respect_gitignore(), expected_gitignore);
}
#[test]
fn test_cli_globs_merged_with_config_globs(
cfg_ext in "[a-z]{2,6}",
cli_ext in "[a-z]{2,6}",
) {
let cfg_glob = format!("*.{cfg_ext}");
let cli_glob = format!("*.{cli_ext}");
let config = ConfigFile {
ignore_globs: vec![cfg_glob],
..Default::default()
};
let cli_override = CliOverride {
ignore_globs: Some(vec![cli_glob]),
..Default::default()
};
let result = config.validate(&cli_override).expect("should validate");
prop_assert!(
result.patterns().should_ignore_glob(Path::new(&format!("app.{cfg_ext}"))),
"config glob pattern should be active"
);
prop_assert!(
result.patterns().should_ignore_glob(Path::new(&format!("app.{cli_ext}"))),
"CLI glob pattern should be active"
);
}
#[test]
fn test_config_file_yaml_round_trip(
include_dotfiles in proptest::bool::ANY,
respect_gitignore in proptest::bool::ANY,
format in prop::option::of(prop::sample::select(vec![
ConfigFormat::Markdown,
ConfigFormat::Tree,
])),
max_depth in prop::option::of(0_usize..=100),
max_clipboard_mb in prop::option::of(0_usize..=1000),
) {
let original = ConfigFile {
include_dotfiles,
respect_gitignore,
format,
max_depth,
max_files: None, max_clipboard_mb,
ignore_extensions: Vec::new(),
ignore_directories: Vec::new(),
ignore_files: Vec::new(),
ignore_globs: Vec::new(),
};
let loaded = round_trip_config(&original);
prop_assert_eq!(original.include_dotfiles, loaded.include_dotfiles);
prop_assert_eq!(original.respect_gitignore, loaded.respect_gitignore);
prop_assert_eq!(original.format, loaded.format);
prop_assert_eq!(original.max_depth, loaded.max_depth);
prop_assert_eq!(original.max_clipboard_mb, loaded.max_clipboard_mb);
}
}
}