use crate::config_validation::validate_config;
use crate::error::{BatlessError, BatlessResult};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BatlessConfig {
#[serde(default = "default_max_lines")]
pub max_lines: usize,
#[serde(default)]
pub max_bytes: Option<usize>,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub strip_ansi: bool,
#[serde(default = "default_use_color")]
pub use_color: bool,
#[serde(default = "default_schema_version")]
pub schema_version: String,
#[serde(default)]
pub debug: bool,
#[serde(default)]
pub show_line_numbers: bool,
#[serde(default)]
pub show_line_numbers_nonblank: bool,
#[serde(default)]
pub pretty_json: bool,
#[serde(default)]
pub json_line_numbers: bool,
#[serde(default)]
pub strip_comments: bool,
#[serde(default)]
pub strip_blank_lines: bool,
}
const fn default_max_lines() -> usize {
10000
}
const fn default_use_color() -> bool {
true
}
fn default_schema_version() -> String {
"2.1".to_string()
}
impl Default for BatlessConfig {
fn default() -> Self {
Self {
max_lines: 10000,
max_bytes: None,
language: None,
strip_ansi: false,
use_color: true,
schema_version: default_schema_version(),
debug: false,
show_line_numbers: false,
show_line_numbers_nonblank: false,
pretty_json: false,
json_line_numbers: false,
strip_comments: false,
strip_blank_lines: false,
}
}
}
impl BatlessConfig {
pub fn new() -> Self {
Self::default()
}
pub const fn with_max_lines(mut self, max_lines: usize) -> Self {
self.max_lines = max_lines;
self
}
pub const fn with_max_bytes(mut self, max_bytes: Option<usize>) -> Self {
self.max_bytes = max_bytes;
self
}
pub fn with_language(mut self, language: Option<String>) -> Self {
self.language = language;
self
}
pub const fn with_strip_ansi(mut self, strip_ansi: bool) -> Self {
self.strip_ansi = strip_ansi;
self
}
pub const fn with_use_color(mut self, use_color: bool) -> Self {
self.use_color = use_color;
self
}
pub fn with_schema_version(mut self, version: String) -> Self {
self.schema_version = version;
self
}
pub const fn with_debug(mut self, debug: bool) -> Self {
self.debug = debug;
self
}
pub const fn with_show_line_numbers(mut self, show_line_numbers: bool) -> Self {
self.show_line_numbers = show_line_numbers;
self
}
pub const fn with_show_line_numbers_nonblank(
mut self,
show_line_numbers_nonblank: bool,
) -> Self {
self.show_line_numbers_nonblank = show_line_numbers_nonblank;
self
}
pub const fn with_pretty_json(mut self, pretty: bool) -> Self {
self.pretty_json = pretty;
self
}
pub const fn with_json_line_numbers(mut self, enabled: bool) -> Self {
self.json_line_numbers = enabled;
self
}
pub const fn with_strip_comments(mut self, enabled: bool) -> Self {
self.strip_comments = enabled;
self
}
pub const fn with_strip_blank_lines(mut self, enabled: bool) -> Self {
self.strip_blank_lines = enabled;
self
}
pub fn validate(&self) -> BatlessResult<()> {
validate_config(self)
}
pub const fn should_use_color(&self, is_terminal: bool) -> bool {
self.use_color && is_terminal
}
pub const fn effective_max_lines(&self) -> usize {
self.max_lines
}
pub const fn has_byte_limit(&self) -> bool {
self.max_bytes.is_some()
}
pub const fn get_byte_limit(&self) -> Option<usize> {
self.max_bytes
}
pub fn from_file<P: AsRef<Path>>(path: P) -> BatlessResult<Self> {
let content = fs::read_to_string(path.as_ref()).map_err(|e| {
BatlessError::config_error_with_help(
format!(
"Failed to read config file '{}': {}",
path.as_ref().display(),
e
),
Some("Check that the file exists and has proper permissions".to_string()),
)
})?;
let config: Self = toml::from_str(&content).map_err(|e| {
BatlessError::config_error_with_help(
format!(
"Failed to parse config file '{}': {}",
path.as_ref().display(),
e
),
Some("Check the TOML syntax - use 'batless --help' for valid options".to_string()),
)
})?;
config.validate()?;
Ok(config)
}
pub fn from_json_file<P: AsRef<Path>>(path: P) -> BatlessResult<Self> {
let content = fs::read_to_string(path.as_ref()).map_err(|e| {
BatlessError::config_error_with_help(
format!(
"Failed to read config file '{}': {}",
path.as_ref().display(),
e
),
Some("Check that the file exists and has proper permissions".to_string()),
)
})?;
let config: Self = serde_json::from_str(&content).map_err(|e| {
BatlessError::config_error_with_help(
format!(
"Failed to parse config file '{}': {}",
path.as_ref().display(),
e
),
Some("Check the JSON syntax - use 'batless --help' for valid options".to_string()),
)
})?;
config.validate()?;
Ok(config)
}
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> BatlessResult<()> {
let content = toml::to_string_pretty(self).map_err(|e| {
BatlessError::config_error_with_help(
format!("Failed to serialize config: {e}"),
Some("This is likely a bug - please report it".to_string()),
)
})?;
fs::write(path.as_ref(), content).map_err(|e| {
BatlessError::config_error_with_help(
format!(
"Failed to write config file '{}': {}",
path.as_ref().display(),
e
),
Some("Check that the directory exists and has write permissions".to_string()),
)
})
}
pub fn find_config_files() -> Vec<PathBuf> {
let mut paths = Vec::new();
paths.push(PathBuf::from(".batlessrc"));
paths.push(PathBuf::from("batless.toml"));
if let Some(home_dir) = dirs::home_dir() {
paths.push(home_dir.join(".batlessrc"));
paths.push(home_dir.join(".config/batless/config.toml"));
paths.push(home_dir.join(".config/batless.toml"));
}
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("batless/config.toml"));
}
paths
}
pub fn load_with_precedence() -> BatlessResult<Self> {
let mut config = Self::default();
for config_path in Self::find_config_files().into_iter().rev() {
if config_path.exists() {
let file_config = if config_path.extension() == Some(std::ffi::OsStr::new("toml")) {
Self::from_file(&config_path)?
} else {
Self::from_json_file(&config_path)?
};
config = config.merge_with(file_config);
}
}
Ok(config)
}
pub fn merge_with(mut self, other: Self) -> Self {
let default = Self::default();
if other.max_lines != default.max_lines {
self.max_lines = other.max_lines;
}
if other.max_bytes != default.max_bytes {
self.max_bytes = other.max_bytes;
}
if other.language != default.language {
self.language = other.language;
}
if other.strip_ansi != default.strip_ansi {
self.strip_ansi = other.strip_ansi;
}
if other.use_color != default.use_color {
self.use_color = other.use_color;
}
if other.schema_version != default.schema_version {
self.schema_version = other.schema_version;
}
if other.debug != default.debug {
self.debug = other.debug;
}
if other.show_line_numbers != default.show_line_numbers {
self.show_line_numbers = other.show_line_numbers;
}
if other.show_line_numbers_nonblank != default.show_line_numbers_nonblank {
self.show_line_numbers_nonblank = other.show_line_numbers_nonblank;
}
if other.pretty_json != default.pretty_json {
self.pretty_json = other.pretty_json;
}
if other.json_line_numbers != default.json_line_numbers {
self.json_line_numbers = other.json_line_numbers;
}
if other.strip_comments != default.strip_comments {
self.strip_comments = other.strip_comments;
}
if other.strip_blank_lines != default.strip_blank_lines {
self.strip_blank_lines = other.strip_blank_lines;
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = BatlessConfig::default();
assert_eq!(config.max_lines, 10000);
assert_eq!(config.max_bytes, None);
assert_eq!(config.language, None);
assert!(!config.strip_ansi);
assert!(config.use_color);
}
#[test]
fn test_builder_pattern() {
let config = BatlessConfig::new()
.with_max_lines(5000)
.with_max_bytes(Some(1024))
.with_language(Some("rust".to_string()))
.with_strip_ansi(true)
.with_use_color(false);
assert_eq!(config.max_lines, 5000);
assert_eq!(config.max_bytes, Some(1024));
assert_eq!(config.language, Some("rust".to_string()));
assert!(config.strip_ansi);
assert!(!config.use_color);
}
#[test]
fn test_validate_delegates_to_config_validation() {
assert!(BatlessConfig::default().validate().is_ok());
assert!(BatlessConfig::default()
.with_max_lines(0)
.validate()
.is_err());
}
#[test]
fn test_should_use_color() {
let config = BatlessConfig::default();
assert!(config.should_use_color(true));
assert!(!config.should_use_color(false));
let config_no_color = config.with_use_color(false);
assert!(!config_no_color.should_use_color(true));
assert!(!config_no_color.should_use_color(false));
}
#[test]
fn test_byte_limit_helpers() {
let config = BatlessConfig::default();
assert!(!config.has_byte_limit());
assert_eq!(config.get_byte_limit(), None);
let config_with_limit = config.with_max_bytes(Some(1024));
assert!(config_with_limit.has_byte_limit());
assert_eq!(config_with_limit.get_byte_limit(), Some(1024));
}
#[test]
fn test_toml_serialization() {
let config = BatlessConfig::default().with_max_lines(5000);
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(toml_str.contains("max_lines = 5000"));
let deserialized: BatlessConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(deserialized.max_lines, 5000);
}
#[test]
fn test_json_serialization() {
let config = BatlessConfig::default().with_max_lines(3000);
let json_str = serde_json::to_string_pretty(&config).unwrap();
let deserialized: BatlessConfig = serde_json::from_str(&json_str).unwrap();
assert_eq!(deserialized.max_lines, 3000);
}
#[test]
fn test_merge_with() {
let base = BatlessConfig::default();
let override_config = BatlessConfig::default()
.with_max_lines(2000)
.with_schema_version("9.9".to_string())
.with_debug(true)
.with_show_line_numbers(true)
.with_show_line_numbers_nonblank(true)
.with_pretty_json(true);
let merged = base.merge_with(override_config);
assert_eq!(merged.max_lines, 2000);
assert_eq!(merged.schema_version, "9.9");
assert!(merged.debug);
assert!(merged.show_line_numbers);
assert!(merged.show_line_numbers_nonblank);
assert!(merged.pretty_json);
assert!(!merged.strip_ansi);
assert!(merged.use_color);
}
#[test]
fn test_config_file_discovery() {
let paths = BatlessConfig::find_config_files();
assert!(!paths.is_empty());
assert!(paths
.iter()
.any(|p| p.file_name() == Some(std::ffi::OsStr::new(".batlessrc"))));
assert!(paths
.iter()
.any(|p| p.file_name() == Some(std::ffi::OsStr::new("batless.toml"))));
}
#[test]
fn test_load_from_toml_file() {
use std::io::Write;
use tempfile::NamedTempFile;
let toml_content = r"
max_lines = 15000
use_color = false
";
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(toml_content.as_bytes()).unwrap();
let config = BatlessConfig::from_file(temp_file.path()).unwrap();
assert_eq!(config.max_lines, 15000);
assert!(!config.use_color);
}
#[test]
fn test_load_from_json_file() {
use std::io::Write;
use tempfile::NamedTempFile;
let json_content = r#"{
"max_lines": 8000,
"strip_ansi": true
}"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(json_content.as_bytes()).unwrap();
let config = BatlessConfig::from_json_file(temp_file.path()).unwrap();
assert_eq!(config.max_lines, 8000);
assert!(config.strip_ansi);
}
#[test]
fn test_invalid_toml_config() {
use std::io::Write;
use tempfile::NamedTempFile;
let invalid_toml = r#"
max_lines = "not_a_number"
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(invalid_toml.as_bytes()).unwrap();
let result = BatlessConfig::from_file(temp_file.path());
assert!(result.is_err());
}
#[test]
fn test_save_to_file() {
use tempfile::NamedTempFile;
let config = BatlessConfig::default().with_max_lines(7000);
let temp_file = NamedTempFile::new().unwrap();
config.save_to_file(temp_file.path()).unwrap();
let loaded_config = BatlessConfig::from_file(temp_file.path()).unwrap();
assert_eq!(loaded_config.max_lines, 7000);
}
}