mod file;
mod patterns;
use crate::{
format::OutputFormat,
printer::{PrinterOptions, SkipPatterns},
};
use std::path::{Path, PathBuf};
#[cfg(feature = "cli")]
use crate::{
cli::Args,
env::{EnvProvider, RealEnv},
error::{Error, Result},
};
#[cfg(feature = "cli")]
use figment::{
Figment, Metadata, Profile, Provider,
providers::Serialized,
value::{Dict, Map, Value},
};
pub use file::{CliOverride, ConfigFile, ConfigFormat, ValidatedConfig, load_config_file};
pub use patterns::IgnorePatterns;
#[cfg(feature = "cli")]
struct EnvOverrides<'a> {
env: &'a dyn EnvProvider,
}
#[cfg(feature = "cli")]
impl EnvOverrides<'_> {
fn parse_bool(&self, key: &str) -> Option<bool> {
let val = self.env.var(key)?;
val.parse::<bool>().map_or_else(
|_| {
log::warn!(
"Ignoring environment variable {key}={val:?}: expected \"true\" or \"false\""
);
None
},
Some,
)
}
fn parse_u64(&self, key: &str) -> Option<u64> {
let val = self.env.var(key)?;
val.parse::<u64>().map_or_else(
|_| {
log::warn!(
"Ignoring environment variable {key}={val:?}: expected a non-negative integer"
);
None
},
Some,
)
}
}
#[cfg(feature = "cli")]
impl Provider for EnvOverrides<'_> {
fn metadata(&self) -> Metadata {
Metadata::named("environment (LUFF_*)")
}
fn data(&self) -> std::result::Result<Map<Profile, Dict>, figment::Error> {
let mut dict = Dict::new();
if let Some(b) = self.parse_bool("LUFF_INCLUDE_DOTFILES") {
let _ = dict.insert("include_dotfiles".into(), Value::from(b));
}
if let Some(b) = self.parse_bool("LUFF_RESPECT_GITIGNORE") {
let _ = dict.insert("respect_gitignore".into(), Value::from(b));
}
if let Some(val) = self.env.var("LUFF_FORMAT") {
let _ = dict.insert("format".into(), Value::from(val));
}
if let Some(n) = self.parse_u64("LUFF_MAX_DEPTH") {
let _ = dict.insert("max_depth".into(), Value::from(n));
}
if let Some(n) = self.parse_u64("LUFF_MAX_FILES") {
let _ = dict.insert("max_files".into(), Value::from(n));
}
if let Some(n) = self.parse_u64("LUFF_MAX_CLIPBOARD_MB") {
let _ = dict.insert("max_clipboard_mb".into(), Value::from(n));
}
Ok(Profile::Default.collect(dict))
}
}
#[derive(Debug, Clone)]
pub struct Config {
validated: ValidatedConfig,
root: PathBuf,
skip_patterns: SkipPatterns,
}
impl Config {
#[cfg(feature = "cli")]
pub fn from_args(args: &Args) -> Result<Self> {
Self::from_args_with_env(args, &RealEnv)
}
#[cfg(feature = "cli")]
pub fn from_args_with_env(args: &Args, env: &dyn EnvProvider) -> Result<Self> {
let root = if args.use_git_root() {
crate::git::find_repository_root()?
} else {
std::env::current_dir().map_err(|e| Error::Config {
message: format!("Failed to get current directory: {e}"),
})?
};
let mut figment = Figment::new().merge(Serialized::defaults(ConfigFile::default()));
if let Some(config_path) = args.config_path() {
let config_from_file = load_config_file(config_path).map_err(|e| Error::Config {
message: format!("Failed to load config file: {e}"),
})?;
figment = figment.merge(Serialized::defaults(config_from_file));
}
figment = figment.merge(EnvOverrides { env });
let raw_config: ConfigFile = figment.extract().map_err(|e| Error::Config {
message: format!("Failed to extract configuration: {e}"),
})?;
let cli_override = args.to_cli_override();
let validated = raw_config
.validate(&cli_override)
.map_err(|e| Error::Config {
message: format!("Configuration validation failed: {e}"),
})?;
Ok(Self::from_validated(validated, root, args))
}
#[cfg(feature = "cli")]
fn from_validated(validated: ValidatedConfig, root: PathBuf, args: &Args) -> Self {
let skip_patterns = if args.has_files() {
SkipPatterns::DISABLED
} else {
SkipPatterns::ENABLED
};
Self {
validated,
root,
skip_patterns,
}
}
#[cfg(test)]
#[must_use]
pub fn new_for_test(root: PathBuf) -> Self {
let config_file = ConfigFile::default();
let validated = config_file
.validate(&CliOverride::default())
.expect("default config must validate");
Self {
validated,
root,
skip_patterns: SkipPatterns::ENABLED,
}
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub const fn include_dotfiles(&self) -> bool {
self.validated.include_dotfiles()
}
#[must_use]
pub const fn respect_gitignore(&self) -> bool {
self.validated.respect_gitignore()
}
#[must_use]
pub const fn patterns(&self) -> &IgnorePatterns {
self.validated.patterns()
}
#[must_use]
pub const fn output_format(&self) -> OutputFormat {
self.validated.format()
}
#[must_use]
pub const fn max_depth(&self) -> usize {
self.validated.max_depth()
}
#[must_use]
pub const fn max_files(&self) -> usize {
self.validated.max_files()
}
#[must_use]
pub const fn max_clipboard_bytes(&self) -> usize {
self.validated
.max_clipboard_mb()
.saturating_mul(1024 * 1024)
}
#[must_use]
pub const fn skip_patterns(&self) -> SkipPatterns {
self.skip_patterns
}
#[must_use]
pub fn printer_options(&self) -> PrinterOptions {
PrinterOptions {
format: self.validated.format(),
root: self.root.clone(),
skip_patterns: self.skip_patterns,
patterns: self.validated.patterns().clone(),
}
}
}
#[cfg(test)]
#[cfg(feature = "cli")]
mod tests {
use super::*;
use crate::env::MockEnv;
use serial_test::serial;
use tempfile::TempDir;
#[test]
#[serial] fn test_config_from_args_with_defaults() {
use clap::Parser;
let temp = TempDir::new().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
let args = Args::parse_from(["luff"]);
let env = MockEnv::new();
let config = Config::from_args_with_env(&args, &env).unwrap();
assert_eq!(config.root(), temp.path().canonicalize().unwrap().as_path());
assert!(!config.include_dotfiles());
assert!(config.respect_gitignore());
assert_eq!(config.output_format(), OutputFormat::Markdown);
assert_eq!(config.max_depth(), 0);
assert_eq!(config.max_files(), 1_000_000);
assert_eq!(config.max_clipboard_bytes(), 100 * 1024 * 1024);
}
#[test]
#[serial] fn test_skip_patterns_when_file_list() {
use clap::Parser;
let temp = TempDir::new().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
let env = MockEnv::new();
let args = Args::parse_from(["luff", "-f", "test.png"]);
let config = Config::from_args_with_env(&args, &env).unwrap();
assert_eq!(config.skip_patterns(), SkipPatterns::DISABLED);
let args = Args::parse_from(["luff"]);
let config = Config::from_args_with_env(&args, &env).unwrap();
assert_eq!(config.skip_patterns(), SkipPatterns::ENABLED);
}
#[test]
#[serial] fn test_clipboard_size_conversion() {
use clap::Parser;
let temp = TempDir::new().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
let env = MockEnv::new();
let args = Args::parse_from(["luff", "--max-clipboard-mb", "50"]);
let config = Config::from_args_with_env(&args, &env).unwrap();
assert_eq!(config.max_clipboard_bytes(), 50 * 1024 * 1024);
}
#[test]
#[serial]
fn test_env_provider_overrides_defaults() {
use clap::Parser;
let temp = TempDir::new().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
let env = MockEnv::new()
.with_var("LUFF_MAX_DEPTH", "42")
.with_var("LUFF_INCLUDE_DOTFILES", "true");
let args = Args::parse_from(["luff"]);
let config = Config::from_args_with_env(&args, &env).unwrap();
assert_eq!(config.max_depth(), 42);
assert!(config.include_dotfiles());
}
#[test]
#[serial]
fn test_env_provider_isolation() {
use clap::Parser;
let temp = TempDir::new().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
let env = MockEnv::new();
let args = Args::parse_from(["luff"]);
let config = Config::from_args_with_env(&args, &env).unwrap();
assert_eq!(config.max_depth(), 0);
assert!(!config.include_dotfiles());
assert!(config.respect_gitignore());
}
#[test]
#[serial]
fn test_env_provider_warns_on_malformed_bool() {
use clap::Parser;
let temp = TempDir::new().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
let env = MockEnv::new().with_var("LUFF_INCLUDE_DOTFILES", "yes");
let args = Args::parse_from(["luff"]);
let config = Config::from_args_with_env(&args, &env).unwrap();
assert!(!config.include_dotfiles());
}
#[test]
#[serial]
fn test_env_provider_warns_on_malformed_u64() {
use clap::Parser;
let temp = TempDir::new().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
let env = MockEnv::new().with_var("LUFF_MAX_DEPTH", "abc");
let args = Args::parse_from(["luff"]);
let config = Config::from_args_with_env(&args, &env).unwrap();
assert_eq!(config.max_depth(), 0);
}
}