use crate::config_validation::validate_config;
use crate::error::{BatlessError, BatlessResult};
use crate::summary::SummaryLevel;
use crate::traits::ProcessingConfig;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum ChunkStrategy {
#[default]
Line,
Semantic,
}
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 = "default_theme")]
pub theme: String,
#[serde(default)]
pub strip_ansi: bool,
#[serde(default = "default_use_color")]
pub use_color: bool,
#[serde(default)]
pub include_tokens: bool,
#[serde(default)]
pub summary_level: SummaryLevel,
#[serde(default)]
pub summary_mode: bool,
#[serde(default)]
pub streaming_json: bool,
#[serde(default = "default_streaming_chunk_size")]
pub streaming_chunk_size: usize,
#[serde(default)]
pub enable_resume: 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 hash: bool,
#[serde(default)]
pub strip_comments: bool,
#[serde(default)]
pub strip_blank_lines: bool,
#[serde(default)]
pub chunk_strategy: ChunkStrategy,
}
fn default_max_lines() -> usize {
10000
}
fn default_theme() -> String {
"base16-ocean.dark".to_string()
}
fn default_use_color() -> bool {
true
}
fn default_streaming_chunk_size() -> usize {
1000
}
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,
theme: "base16-ocean.dark".to_string(),
strip_ansi: false,
use_color: true,
include_tokens: false,
summary_level: SummaryLevel::None,
summary_mode: false,
streaming_json: false,
streaming_chunk_size: default_streaming_chunk_size(),
enable_resume: false,
schema_version: default_schema_version(),
debug: false,
show_line_numbers: false,
show_line_numbers_nonblank: false,
pretty_json: false,
json_line_numbers: false,
hash: false,
strip_comments: false,
strip_blank_lines: false,
chunk_strategy: ChunkStrategy::Line,
}
}
}
impl BatlessConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_lines(mut self, max_lines: usize) -> Self {
self.max_lines = max_lines;
self
}
pub 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 fn with_theme(mut self, theme: String) -> Self {
self.theme = theme;
self
}
pub fn with_strip_ansi(mut self, strip_ansi: bool) -> Self {
self.strip_ansi = strip_ansi;
self
}
pub fn with_use_color(mut self, use_color: bool) -> Self {
self.use_color = use_color;
self
}
pub fn with_include_tokens(mut self, include_tokens: bool) -> Self {
self.include_tokens = include_tokens;
self
}
pub fn with_summary_mode(mut self, summary_mode: bool) -> Self {
self.summary_mode = summary_mode;
if summary_mode {
self.summary_level = SummaryLevel::Standard;
} else {
self.summary_level = SummaryLevel::None;
}
self
}
pub fn with_summary_level(mut self, summary_level: SummaryLevel) -> Self {
self.summary_mode = summary_level.is_enabled();
self.summary_level = summary_level;
self
}
pub fn with_streaming_json(mut self, streaming_json: bool) -> Self {
self.streaming_json = streaming_json;
self
}
pub fn with_streaming_chunk_size(mut self, chunk_size: usize) -> Self {
self.streaming_chunk_size = chunk_size;
self
}
pub fn with_enable_resume(mut self, enable_resume: bool) -> Self {
self.enable_resume = enable_resume;
self
}
pub fn with_schema_version(mut self, version: String) -> Self {
self.schema_version = version;
self
}
pub fn with_debug(mut self, debug: bool) -> Self {
self.debug = debug;
self
}
pub fn with_show_line_numbers(mut self, show_line_numbers: bool) -> Self {
self.show_line_numbers = show_line_numbers;
self
}
pub 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 fn with_pretty_json(mut self, pretty: bool) -> Self {
self.pretty_json = pretty;
self
}
pub fn with_json_line_numbers(mut self, enabled: bool) -> Self {
self.json_line_numbers = enabled;
self
}
pub fn with_hash(mut self, enabled: bool) -> Self {
self.hash = enabled;
self
}
pub fn with_strip_comments(mut self, enabled: bool) -> Self {
self.strip_comments = enabled;
self
}
pub fn with_strip_blank_lines(mut self, enabled: bool) -> Self {
self.strip_blank_lines = enabled;
self
}
pub fn with_chunk_strategy(mut self, strategy: ChunkStrategy) -> Self {
self.chunk_strategy = strategy;
self
}
pub fn effective_summary_level(&self) -> SummaryLevel {
if self.summary_level != SummaryLevel::None {
self.summary_level
} else if self.summary_mode {
SummaryLevel::Standard
} else {
SummaryLevel::None
}
}
pub fn validate(&self) -> BatlessResult<()> {
validate_config(self)
}
pub fn should_use_color(&self, is_terminal: bool) -> bool {
self.use_color && is_terminal
}
pub fn effective_max_lines(&self) -> usize {
self.max_lines
}
pub fn has_byte_limit(&self) -> bool {
self.max_bytes.is_some()
}
pub 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: BatlessConfig = 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: BatlessConfig = 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.theme != default.theme {
self.theme = other.theme;
}
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.include_tokens != default.include_tokens {
self.include_tokens = other.include_tokens;
}
if other.summary_mode != default.summary_mode {
self.summary_mode = other.summary_mode;
}
if other.summary_level != default.summary_level {
self.summary_level = other.summary_level;
}
if other.streaming_json != default.streaming_json {
self.streaming_json = other.streaming_json;
}
if other.streaming_chunk_size != default.streaming_chunk_size {
self.streaming_chunk_size = other.streaming_chunk_size;
}
if other.enable_resume != default.enable_resume {
self.enable_resume = other.enable_resume;
}
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.hash != default.hash {
self.hash = other.hash;
}
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;
}
if other.chunk_strategy != default.chunk_strategy {
self.chunk_strategy = other.chunk_strategy;
}
self
}
}
impl ProcessingConfig for BatlessConfig {
fn max_lines(&self) -> usize {
self.max_lines
}
fn max_bytes(&self) -> Option<usize> {
self.max_bytes
}
fn language(&self) -> Option<&str> {
self.language.as_deref()
}
fn summary_mode(&self) -> bool {
self.summary_mode
}
fn include_tokens(&self) -> bool {
self.include_tokens
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{profile::CustomProfile, summary::SummaryLevel};
#[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_eq!(config.theme, "base16-ocean.dark");
assert!(!config.strip_ansi);
assert!(config.use_color);
assert!(!config.include_tokens);
assert!(!config.summary_mode);
}
#[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_theme("monokai".to_string())
.with_strip_ansi(true)
.with_use_color(false)
.with_include_tokens(true)
.with_summary_mode(true);
assert_eq!(config.max_lines, 5000);
assert_eq!(config.max_bytes, Some(1024));
assert_eq!(config.language, Some("rust".to_string()));
assert_eq!(config.theme, "monokai");
assert!(config.strip_ansi);
assert!(!config.use_color);
assert!(config.include_tokens);
assert!(config.summary_mode);
}
#[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)
.with_theme("monokai".to_string());
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(toml_str.contains("max_lines = 5000"));
assert!(toml_str.contains("theme = \"monokai\""));
let deserialized: BatlessConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(deserialized.max_lines, 5000);
assert_eq!(deserialized.theme, "monokai");
}
#[test]
fn test_json_serialization() {
let config = BatlessConfig::default()
.with_max_lines(3000)
.with_include_tokens(true);
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);
assert!(deserialized.include_tokens);
}
#[test]
fn test_merge_with() {
let base = BatlessConfig::default();
let override_config = BatlessConfig::default()
.with_max_lines(2000)
.with_theme("solarized".to_string())
.with_summary_level(SummaryLevel::Detailed)
.with_streaming_json(true)
.with_streaming_chunk_size(42)
.with_enable_resume(true)
.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.theme, "solarized");
assert_eq!(merged.summary_level, SummaryLevel::Detailed);
assert!(merged.streaming_json);
assert_eq!(merged.streaming_chunk_size, 42);
assert!(merged.enable_resume);
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
theme = "zenburn"
use_color = false
summary_mode = true
"#;
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_eq!(config.theme, "zenburn");
assert!(!config.use_color);
assert!(config.summary_mode);
}
#[test]
fn test_load_from_json_file() {
use std::io::Write;
use tempfile::NamedTempFile;
let json_content = r#"{
"max_lines": 8000,
"theme": "github",
"include_tokens": true,
"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_eq!(config.theme, "github");
assert!(config.include_tokens);
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)
.with_theme("dracula".to_string());
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);
assert_eq!(loaded_config.theme, "dracula");
}
#[test]
fn test_custom_profile_creation() {
let profile = CustomProfile::new(
"test-profile".to_string(),
Some("A test profile for unit testing".to_string()),
);
assert_eq!(profile.name, "test-profile");
assert_eq!(
profile.description,
Some("A test profile for unit testing".to_string())
);
assert_eq!(profile.version, "1.0");
assert!(profile.max_lines.is_none());
assert!(profile.max_bytes.is_none());
assert!(profile.tags.is_empty());
}
#[test]
fn test_custom_profile_apply_to_config() {
let profile = CustomProfile {
name: "coding-profile".to_string(),
description: None,
version: "1.0".to_string(),
max_lines: Some(2500),
max_bytes: Some(50000),
language: Some("rust".to_string()),
theme: Some("zenburn".to_string()),
strip_ansi: Some(true),
use_color: Some(false),
include_tokens: Some(true),
summary_level: Some(SummaryLevel::Standard),
output_mode: Some("json".to_string()),
ai_model: Some("gpt4-turbo".to_string()),
streaming_json: Some(false),
streaming_chunk_size: Some(1000),
enable_resume: Some(false),
debug: Some(false),
tags: vec!["coding".to_string(), "development".to_string()],
created_at: None,
updated_at: None,
};
let base_config = BatlessConfig::default();
let applied_config = profile.apply_to_config(base_config);
assert_eq!(applied_config.max_lines, 2500);
assert_eq!(applied_config.max_bytes, Some(50000));
assert_eq!(applied_config.language, Some("rust".to_string()));
assert_eq!(applied_config.theme, "zenburn");
assert!(applied_config.strip_ansi);
assert!(!applied_config.use_color);
assert!(applied_config.include_tokens);
assert_eq!(applied_config.summary_level, SummaryLevel::Standard);
}
#[test]
fn test_custom_profile_partial_application() {
let profile = CustomProfile {
name: "minimal-profile".to_string(),
description: None,
version: "1.0".to_string(),
max_lines: Some(1000),
max_bytes: None,
language: None,
theme: None,
strip_ansi: None,
use_color: None,
include_tokens: None,
summary_level: None,
output_mode: None,
ai_model: None,
streaming_json: None,
streaming_chunk_size: None,
enable_resume: None,
debug: None,
tags: Vec::new(),
created_at: None,
updated_at: None,
};
let base_config = BatlessConfig::default()
.with_theme("monokai".to_string())
.with_use_color(false);
let applied_config = profile.apply_to_config(base_config);
assert_eq!(applied_config.max_lines, 1000);
assert_eq!(applied_config.theme, "monokai"); assert!(!applied_config.use_color); }
#[test]
fn test_custom_profile_validation() {
let valid_profile = CustomProfile::new(
"valid-profile".to_string(),
Some("A valid profile".to_string()),
);
assert!(valid_profile.validate().is_ok());
let empty_name_profile = CustomProfile::new(String::new(), None);
assert!(empty_name_profile.validate().is_err());
let long_name_profile = CustomProfile::new("a".repeat(60), None);
assert!(long_name_profile.validate().is_err());
}
#[test]
fn test_custom_profile_output_mode_preference() {
let profile = CustomProfile {
name: "test".to_string(),
description: None,
version: "1.0".to_string(),
max_lines: None,
max_bytes: None,
language: None,
theme: None,
strip_ansi: None,
use_color: None,
include_tokens: None,
summary_level: None,
output_mode: Some("summary".to_string()),
ai_model: Some("claude35-sonnet".to_string()),
streaming_json: None,
streaming_chunk_size: None,
enable_resume: None,
debug: None,
tags: Vec::new(),
created_at: None,
updated_at: None,
};
assert_eq!(profile.get_output_mode(), Some("summary"));
assert_eq!(profile.get_ai_model(), Some("claude35-sonnet"));
}
#[test]
fn test_custom_profile_json_serialization() {
let profile = CustomProfile::new(
"test-profile".to_string(),
Some("Test description".to_string()),
);
let json_str = serde_json::to_string_pretty(&profile).unwrap();
let deserialized: CustomProfile = serde_json::from_str(&json_str).unwrap();
assert_eq!(deserialized.name, profile.name);
assert_eq!(deserialized.description, profile.description);
assert_eq!(deserialized.version, profile.version);
}
#[test]
fn test_custom_profile_discover_profiles() {
let profiles = CustomProfile::discover_profiles();
assert!(profiles.is_empty() || !profiles.is_empty());
}
}