use crate::commands::args::ConfigAction;
use crate::error::{ConfigErrorKind, LlaError, Result};
use crate::theme::{load_theme, Theme};
use chrono::format::{Item, StrftimeItems};
use colored::*;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, HashMap};
use std::env;
use std::fmt::Display;
use std::fs;
use std::path::{Path, PathBuf};
use toml::Value as TomlValue;
pub const DEFAULT_LONG_DATE_FORMAT: &str = "%b %d %H:%M";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TreeFormatterConfig {
#[serde(default)]
pub max_lines: Option<usize>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GridFormatterConfig {
#[serde(default)]
pub ignore_width: bool,
#[serde(default = "default_grid_max_width")]
pub max_width: usize,
}
impl Default for GridFormatterConfig {
fn default() -> Self {
Self {
ignore_width: false,
max_width: default_grid_max_width(),
}
}
}
fn default_grid_max_width() -> usize {
200
}
impl Default for TreeFormatterConfig {
fn default() -> Self {
Self {
max_lines: Some(20_000),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct SizeMapConfig {}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableFormatterConfig {
#[serde(default = "default_table_columns")]
pub columns: Vec<String>,
}
impl Default for TableFormatterConfig {
fn default() -> Self {
Self {
columns: default_table_columns(),
}
}
}
fn default_table_columns() -> Vec<String> {
vec![
"permissions".to_string(),
"size".to_string(),
"modified".to_string(),
"name".to_string(),
]
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FormatterConfig {
#[serde(default)]
pub tree: TreeFormatterConfig,
#[serde(default)]
pub grid: GridFormatterConfig,
#[serde(default)]
pub long: LongFormatterConfig,
#[serde(default)]
pub table: TableFormatterConfig,
#[serde(default)]
pub sizemap: SizeMapConfig,
}
impl Default for FormatterConfig {
fn default() -> Self {
Self {
tree: TreeFormatterConfig::default(),
grid: GridFormatterConfig::default(),
long: LongFormatterConfig::default(),
table: TableFormatterConfig::default(),
sizemap: SizeMapConfig::default(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LongFormatterConfig {
#[serde(default)]
pub hide_group: bool,
#[serde(default)]
pub relative_dates: bool,
#[serde(default = "default_long_date_format")]
pub date_format: String,
#[serde(default = "default_long_columns")]
pub columns: Vec<String>,
}
impl Default for LongFormatterConfig {
fn default() -> Self {
Self {
hide_group: false,
relative_dates: false,
date_format: default_long_date_format(),
columns: default_long_columns(),
}
}
}
fn default_long_date_format() -> String {
DEFAULT_LONG_DATE_FORMAT.to_string()
}
fn default_long_columns() -> Vec<String> {
vec![
"permissions".to_string(),
"size".to_string(),
"modified".to_string(),
"user".to_string(),
"group".to_string(),
"name".to_string(),
]
}
pub fn validate_long_date_format(key: &str, value: &str) -> Result<()> {
if value.trim().is_empty() {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must not be empty".to_string(),
)));
}
if StrftimeItems::new(value).any(|item| matches!(item, Item::Error)) {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be a valid chrono strftime format".to_string(),
)));
}
Ok(())
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RecursiveConfig {
#[serde(default)]
pub max_entries: Option<usize>,
}
impl Default for RecursiveConfig {
fn default() -> Self {
Self {
max_entries: Some(20_000),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FuzzyConfig {
#[serde(default = "default_ignore_patterns")]
pub ignore_patterns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub editor: Option<String>,
}
impl Default for FuzzyConfig {
fn default() -> Self {
Self {
ignore_patterns: default_ignore_patterns(),
editor: None,
}
}
}
fn default_ignore_patterns() -> Vec<String> {
vec![
String::from("node_modules"),
String::from("target"),
String::from(".git"),
String::from(".idea"),
String::from(".vscode"),
]
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct ListerConfig {
#[serde(default)]
pub recursive: RecursiveConfig,
#[serde(default)]
pub fuzzy: FuzzyConfig,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SortConfig {
#[serde(default)]
pub dirs_first: bool,
#[serde(default)]
pub case_sensitive: bool,
#[serde(default)]
pub natural: bool,
}
impl Default for SortConfig {
fn default() -> Self {
Self {
dirs_first: false,
case_sensitive: false,
natural: true,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct FilterConfig {
#[serde(default)]
pub case_sensitive: bool,
#[serde(default)]
pub no_dotfiles: bool,
#[serde(default)]
pub respect_gitignore: bool,
#[serde(default)]
pub presets: HashMap<String, FilterPreset>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct FilterPreset {
pub description: Option<String>,
pub filter: Option<String>,
pub size: Option<String>,
pub modified: Option<String>,
pub created: Option<String>,
#[serde(default)]
pub refine: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
pub default_sort: String,
pub default_format: String,
pub enabled_plugins: Vec<String>,
#[serde(deserialize_with = "deserialize_path_with_tilde")]
pub plugins_dir: PathBuf,
#[serde(default, deserialize_with = "deserialize_paths_with_tilde")]
pub exclude_paths: Vec<PathBuf>,
pub default_depth: Option<usize>,
#[serde(default)]
pub show_icons: bool,
#[serde(default)]
pub include_dirs: bool,
#[serde(default)]
pub sort: SortConfig,
#[serde(default)]
pub filter: FilterConfig,
#[serde(default)]
pub formatters: FormatterConfig,
#[serde(default)]
pub listers: ListerConfig,
#[serde(default)]
pub shortcuts: HashMap<String, ShortcutCommand>,
#[serde(default)]
pub plugin_aliases: HashMap<String, String>,
#[serde(default = "default_theme_name")]
pub theme: String,
#[serde(default = "default_permission_format")]
pub permission_format: String,
}
fn deserialize_path_with_tilde<'de, D>(deserializer: D) -> std::result::Result<PathBuf, D::Error>
where
D: serde::Deserializer<'de>,
{
let path_str = String::deserialize(deserializer)?;
if path_str.starts_with('~') {
let home = dirs::home_dir()
.ok_or_else(|| serde::de::Error::custom("Could not determine home directory"))?;
Ok(home.join(&path_str[2..]))
} else {
Ok(PathBuf::from(path_str))
}
}
fn deserialize_paths_with_tilde<'de, D>(
deserializer: D,
) -> std::result::Result<Vec<PathBuf>, D::Error>
where
D: serde::Deserializer<'de>,
{
let list = Vec::<String>::deserialize(deserializer)?;
let mut result = Vec::with_capacity(list.len());
for s in list {
if s.starts_with('~') {
let home = dirs::home_dir()
.ok_or_else(|| serde::de::Error::custom("Could not determine home directory"))?;
result.push(home.join(&s[2..]));
} else {
result.push(PathBuf::from(s));
}
}
Ok(result)
}
fn default_theme_name() -> String {
"default".to_string()
}
fn default_permission_format() -> String {
"symbolic".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShortcutCommand {
pub plugin_name: String,
pub action: String,
pub description: Option<String>,
}
#[derive(Clone)]
pub struct ConfigLayers {
pub default: Config,
pub global: Config,
pub effective: Config,
pub profile_path: Option<PathBuf>,
}
pub fn load_config_layers(start_dir: Option<&Path>) -> Result<(ConfigLayers, Option<LlaError>)> {
let default_config = Config::default();
let config_path = Config::get_config_path();
let (global_config, config_error) = match Config::load(&config_path) {
Ok(cfg) => (cfg, None),
Err(err) => (Config::default(), Some(err)),
};
let mut effective_config = global_config.clone();
let profile_path = match find_profile_file(start_dir)? {
Some(path) => {
if let Err(profile_err) = effective_config.apply_profile_file(&path) {
return Err(profile_err);
}
Some(path)
}
None => None,
};
Ok((
ConfigLayers {
default: default_config,
global: global_config,
effective: effective_config,
profile_path,
},
config_error,
))
}
pub fn find_profile_file(start_dir: Option<&Path>) -> Result<Option<PathBuf>> {
let mut current_dir = match start_dir {
Some(dir) => dir.to_path_buf(),
None => env::current_dir()?,
};
loop {
let candidate = current_dir.join(".lla.toml");
if candidate.is_file() {
return Ok(Some(candidate));
}
if !current_dir.pop() {
break;
}
}
Ok(None)
}
impl Config {
#[allow(dead_code)]
pub fn new() -> Self {
Config::default()
}
pub fn load(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
if path.exists() {
let contents = fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
config.ensure_plugins_dir()?;
config.validate()?;
Ok(config)
} else {
let config = Config::default();
config.ensure_plugins_dir()?;
config.save(path)?;
Ok(config)
}
}
fn generate_config_content(&self) -> String {
let plugins_dir_str = self.plugins_dir.to_string_lossy();
let plugins_dir_display = if let Some(home) = dirs::home_dir() {
if let Ok(relative) = self.plugins_dir.strip_prefix(&home) {
format!("~/{}", relative.to_string_lossy())
} else {
plugins_dir_str.to_string()
}
} else {
plugins_dir_str.to_string()
};
let format_string = |value: &str| TomlValue::String(value.to_string()).to_string();
let long_columns = TomlValue::Array(
self.formatters
.long
.columns
.iter()
.map(|c| TomlValue::String(c.clone()))
.collect(),
)
.to_string();
let table_columns = TomlValue::Array(
self.formatters
.table
.columns
.iter()
.map(|c| TomlValue::String(c.clone()))
.collect(),
)
.to_string();
let mut content = format!(
r#"# lla Configuration File
# This file controls the behavior and appearance of the lla command
# Default sorting method for file listings
# Possible values:
# - "name": Sort alphabetically by filename (default)
# - "size": Sort by file size, largest first
# - "date": Sort by modification time, newest first
default_sort = "{}"
# Default format for displaying files
# Possible values:
# - "default": Quick and clean directory listing
# - "long": Detailed file information with metadata
# - "tree": Hierarchical directory visualization
# - "fuzzy": Interactive fuzzy search
# - "grid": Organized grid layout for better readability
# - "git": Git-aware view with repository status
# - "timeline": Group files by time periods
# - "sizemap": Visual representation of file sizes
# - "table": Structured data display
default_format = "{}"
# Whether to show icons by default
# When true, file and directory icons will be displayed in all views
# Default: false
show_icons = {}
# Whether to include directory sizes in file listings
# When true, directory sizes will be calculated recursively
# This may impact performance for large directories; some views skip this when size is not used
# Default: false
include_dirs = {}
# Format for displaying file permissions
# Possible values:
# - "symbolic": Traditional Unix-style (e.g., -rw-r--r--)
# - "octal": Numeric mode (e.g., d644)
# - "binary": Binary representation (e.g., 110100100)
# - "compact": Compact representation (e.g., 644)
# - "verbose": Verbose representation (e.g., type:file owner:rwx group:r-x others:r-x)
# Default: "symbolic"
permission_format = "{}"
# The theme to use for coloring
# Place custom themes in ~/.config/lla/themes/
# Default: "default"
theme = "{}"
# List of enabled plugins
# Each plugin provides additional functionality
# Examples:
# - "git_status": Show Git repository information
# - "file_hash": Calculate and display file hashes
# - "file_tagger": Add and manage file tags
enabled_plugins = {}
# Directory where plugins are stored
# Default: ~/.config/lla/plugins
plugins_dir = "{}"
# Paths to exclude from listings (tilde is supported)
# Examples:
# - "~/Library/Mobile Documents" # macOS iCloud Drive (Mobile Documents)
# - "~/Library/CloudStorage" # macOS cloud storage providers
# Default: [] (no exclusions)
exclude_paths = {}
# Maximum depth for recursive directory traversal
# Controls how deep lla will go when showing directory contents
# Set to None for unlimited depth (may impact performance)
# Default: 3 levels deep
default_depth = {}
# Sorting configuration
[sort]
# List directories before files
# Default: false
dirs_first = {}
# Enable case-sensitive sorting
# Default: false
case_sensitive = {}
# Use natural sorting for numbers (e.g., 2.txt before 10.txt)
# Default: true
natural = {}
# Filtering configuration
[filter]
# Enable case-sensitive filtering by default
# Default: false
case_sensitive = {}
# Hide dot files and directories by default
# Default: false
no_dotfiles = {}
# Respect .gitignore (and git exclude) rules when listing files
# Default: false
respect_gitignore = {}
# Named filter presets let you reuse complex filter combinations
# Uncomment and customize the example below or define your own under [filter.presets.<name>]
# [filter.presets.rust_sources]
# description = "Common Rust sources"
# filter = "glob:*.{{rs,toml}}"
# size = "<2M"
# modified = "<30d"
# Formatter-specific configurations
[formatters.tree]
# Maximum number of entries to display in tree view
# Controls memory usage and performance for large directories
# Set to 0 to show all entries (may impact performance)
# Default: 20000 entries
max_lines = {}
# Grid formatter configuration
[formatters.grid]
# Whether to ignore terminal width by default
# When true, grid view will use max_width instead of terminal width
# Default: false
ignore_width = {}
# Maximum width for grid view when ignore_width is true
# This value is used when terminal width is ignored
# Default: 200 columns
max_width = {}
# Long formatter configuration
[formatters.long]
# Hide the group column in long format
# Default: false
hide_group = {}
# Show relative dates (e.g., "2h ago") in long format
# Default: false
relative_dates = {}
# Date/time format for absolute dates in long format
# Uses chrono strftime syntax (e.g., "%Y-%m-%d %H:%M" to show the year)
# Default: "%b %d %H:%M"
date_format = "{}"
# Column order for long view (use built-in keys or field:<custom_field> for plugin data)
columns = {}
# Table formatter configuration
[formatters.table]
# Columns rendered in table view (same keys as long view; include plugin fields via field:<name>)
columns = {}
# Lister-specific configurations
[listers.recursive]
# Maximum number of entries to process in recursive listing
# Controls memory usage and performance for deep directory structures
# Set to 0 to process all entries (may impact performance)
# Default: 20000 entries
max_entries = {}
# Fuzzy lister configuration
[listers.fuzzy]
# Patterns to ignore when listing files in fuzzy mode
# Can be:
# - Simple substring match: "node_modules"
# - Glob pattern: "glob:*.min.js"
# - Regular expression: "regex:.*\\.pyc$"
# Default: ["node_modules", "target", ".git", ".idea", ".vscode"]
ignore_patterns = {}
# Editor to use for editing files in fuzzy view
# Overrides the $EDITOR environment variable if set
# Examples: "nvim", "vim", "nano", "code"
# Default: "" (falls back to $EDITOR or $VISUAL, then nano)
editor = {}"#,
self.default_sort,
self.default_format,
self.show_icons,
self.include_dirs,
self.permission_format,
self.theme,
serde_json::to_string(&self.enabled_plugins).unwrap_or_else(|_| "[]".to_string()),
plugins_dir_display,
{
let home = dirs::home_dir();
let display_paths: Vec<String> = self
.exclude_paths
.iter()
.map(|p| {
if let Some(home) = &home {
if let Ok(relative) = p.strip_prefix(home) {
format!("~/{}", relative.to_string_lossy())
} else {
p.to_string_lossy().to_string()
}
} else {
p.to_string_lossy().to_string()
}
})
.collect();
serde_json::to_string(&display_paths).unwrap_or_else(|_| "[]".to_string())
},
match self.default_depth {
Some(depth) => depth.to_string(),
None => "null".to_string(),
},
self.sort.dirs_first,
self.sort.case_sensitive,
self.sort.natural,
self.filter.case_sensitive,
self.filter.no_dotfiles,
self.filter.respect_gitignore,
self.formatters.tree.max_lines.unwrap_or(0),
self.formatters.grid.ignore_width,
self.formatters.grid.max_width,
self.formatters.long.hide_group,
self.formatters.long.relative_dates,
self.formatters.long.date_format,
long_columns,
table_columns,
self.listers.recursive.max_entries.unwrap_or(0),
serde_json::to_string(&self.listers.fuzzy.ignore_patterns)
.unwrap_or_else(|_| "[]".to_string()),
self.listers
.fuzzy
.editor
.as_ref()
.map(|e| TomlValue::String(e.clone()).to_string())
.unwrap_or_else(|| TomlValue::String(String::new()).to_string()),
);
if !self.filter.presets.is_empty() {
content.push('\n');
content.push_str("# Saved filter presets\n");
for (name, preset) in &self.filter.presets {
content.push_str(&format!("[filter.presets.{}]\n", name));
if let Some(desc) = &preset.description {
content.push_str(&format!("description = {}\n", format_string(desc)));
}
if let Some(pattern) = &preset.filter {
content.push_str(&format!("filter = {}\n", format_string(pattern)));
}
if let Some(size) = &preset.size {
content.push_str(&format!("size = {}\n", format_string(size)));
}
if let Some(modified) = &preset.modified {
content.push_str(&format!("modified = {}\n", format_string(modified)));
}
if let Some(created) = &preset.created {
content.push_str(&format!("created = {}\n", format_string(created)));
}
if !preset.refine.is_empty() {
let arr = TomlValue::Array(
preset
.refine
.iter()
.map(|v| TomlValue::String(v.clone()))
.collect(),
)
.to_string();
content.push_str(&format!("refine = {}\n", arr));
}
content.push('\n');
}
}
if !self.shortcuts.is_empty() {
content.push_str("\n\n# Command shortcuts\n");
content.push_str("# Define custom shortcuts for frequently used plugin commands\n");
content.push_str("[shortcuts]\n");
for (name, cmd) in &self.shortcuts {
content.push_str(&format!(
r#"{}={{ plugin_name = "{}", action = "{}""#,
name, cmd.plugin_name, cmd.action
));
if let Some(desc) = &cmd.description {
content.push_str(&format!(r#", description = "{}""#, desc));
}
content.push_str("}\n");
}
}
content
}
pub fn save(&self, path: &Path) -> Result<()> {
self.validate()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
self.ensure_plugins_dir()?;
fs::write(path, self.generate_config_content())?;
Ok(())
}
pub fn apply_profile_file(&mut self, profile_path: &Path) -> Result<()> {
if !profile_path.is_file() {
return Err(LlaError::Config(ConfigErrorKind::InvalidPath(
profile_path.display().to_string(),
)));
}
let contents = fs::read_to_string(profile_path)?;
let overlay: TomlValue = toml::from_str(&contents)?;
self.apply_profile_value(&overlay)
}
fn apply_profile_value(&mut self, overlay: &TomlValue) -> Result<()> {
let mut base_value = TomlValue::try_from(self.clone())
.map_err(|err| LlaError::Config(ConfigErrorKind::InvalidFormat(err.to_string())))?;
merge_toml_values(&mut base_value, overlay);
let merged: Config = base_value.try_into().map_err(|err: toml::de::Error| {
LlaError::Config(ConfigErrorKind::InvalidFormat(err.to_string()))
})?;
*self = merged;
self.ensure_plugins_dir()?;
self.validate()?;
Ok(())
}
pub fn get_config_path() -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".config").join("lla").join("config.toml")
}
pub fn ensure_plugins_dir(&self) -> Result<()> {
fs::create_dir_all(&self.plugins_dir).map_err(|e| {
LlaError::Config(ConfigErrorKind::InvalidPath(format!(
"Failed to create plugins directory: {}",
e
)))
})
}
#[cfg(feature = "dynamic-plugins")]
pub fn enable_plugin(&mut self, plugin_name: &str) -> Result<()> {
self.ensure_plugins_dir().map_err(|e| {
LlaError::Config(ConfigErrorKind::InvalidPath(format!(
"Failed to create plugins directory: {}",
e
)))
})?;
if !self.enabled_plugins.contains(&plugin_name.to_string()) {
self.enabled_plugins.push(plugin_name.to_string());
self.save(&Self::get_config_path())?;
}
Ok(())
}
#[cfg(feature = "dynamic-plugins")]
pub fn disable_plugin(&mut self, plugin_name: &str) -> Result<()> {
self.enabled_plugins.retain(|name| name != plugin_name);
self.save(&Self::get_config_path())?;
Ok(())
}
pub fn add_shortcut(&mut self, name: String, command: ShortcutCommand) -> Result<()> {
if name.is_empty() {
return Err(LlaError::Config(ConfigErrorKind::ValidationError(
"Shortcut name cannot be empty".to_string(),
)));
}
if command.plugin_name.is_empty() {
return Err(LlaError::Config(ConfigErrorKind::ValidationError(
"Plugin name cannot be empty".to_string(),
)));
}
if command.action.is_empty() {
return Err(LlaError::Config(ConfigErrorKind::ValidationError(
"Action cannot be empty".to_string(),
)));
}
self.shortcuts.insert(name, command);
self.save(&Self::get_config_path())?;
Ok(())
}
pub fn remove_shortcut(&mut self, name: &str) -> Result<()> {
self.shortcuts.remove(name);
self.save(&Self::get_config_path())?;
Ok(())
}
pub fn get_shortcut(&self, name: &str) -> Option<&ShortcutCommand> {
self.shortcuts.get(name)
}
pub fn resolve_plugin_alias(&self, name: &str) -> String {
self.plugin_aliases
.get(name)
.cloned()
.unwrap_or_else(|| name.to_string())
}
pub fn validate(&self) -> Result<()> {
if !["name", "size", "date"].contains(&self.default_sort.as_str()) {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
"default_sort".to_string(),
format!(
"Invalid sort value: {}. Must be one of: name, size, date",
self.default_sort
),
)));
}
let valid_formats = [
"default", "long", "tree", "grid", "git", "timeline", "sizemap", "table", "fuzzy",
];
if !valid_formats.contains(&self.default_format.as_str()) {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
"default_format".to_string(),
format!(
"Invalid format value: {}. Must be one of: {}",
self.default_format,
valid_formats.join(", ")
),
)));
}
if !self.plugins_dir.exists() {
return Err(LlaError::Config(ConfigErrorKind::InvalidPath(format!(
"Plugins directory does not exist: {}",
self.plugins_dir.display()
))));
}
for p in &self.exclude_paths {
if p.as_os_str().is_empty() {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
"exclude_paths".to_string(),
"exclude_paths cannot contain empty paths".to_string(),
)));
}
}
if let Some(depth) = self.default_depth {
if depth == 0 {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
"default_depth".to_string(),
"Depth must be greater than 0 or None".to_string(),
)));
}
}
if let Some(max_lines) = self.formatters.tree.max_lines {
if max_lines > 100_000 {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
"formatters.tree.max_lines".to_string(),
"Tree formatter max lines should not exceed 100,000".to_string(),
)));
}
}
validate_long_date_format(
"formatters.long.date_format",
&self.formatters.long.date_format,
)?;
if let Some(max_entries) = self.listers.recursive.max_entries {
if max_entries > 100_000 {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
"listers.recursive.max_entries".to_string(),
"Recursive lister max entries should not exceed 100,000".to_string(),
)));
}
}
#[cfg(feature = "dynamic-plugins")]
for plugin in &self.enabled_plugins {
let possible_names = [
format!("lib{}.dylib", plugin),
format!("lib{}.so", plugin),
format!("{}.dll", plugin),
format!("{}.dylib", plugin),
format!("{}.so", plugin),
plugin.clone(),
];
let exists = possible_names
.iter()
.any(|name| self.plugins_dir.join(name).exists());
if !exists {
return Err(LlaError::Config(ConfigErrorKind::ValidationError(format!(
"Enabled plugin not found: {}",
plugin
))));
}
}
for (name, cmd) in &self.shortcuts {
if name.is_empty() {
return Err(LlaError::Config(ConfigErrorKind::ValidationError(
"Shortcut name cannot be empty".to_string(),
)));
}
if cmd.plugin_name.is_empty() {
return Err(LlaError::Config(ConfigErrorKind::ValidationError(format!(
"Plugin name cannot be empty for shortcut: {}",
name
))));
}
if cmd.action.is_empty() {
return Err(LlaError::Config(ConfigErrorKind::ValidationError(format!(
"Action cannot be empty for shortcut: {}",
name
))));
}
}
Ok(())
}
pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
self.set_value_at_path(key, value, &Self::get_config_path())
}
fn set_value_at_path(&mut self, key: &str, value: &str, config_path: &Path) -> Result<()> {
match key.split('.').collect::<Vec<_>>().as_slice() {
["plugins_dir"] => {
let new_dir = PathBuf::from(value);
fs::create_dir_all(&new_dir).map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidPath(format!(
"Failed to create directory: {}",
new_dir.display()
)))
})?;
self.plugins_dir = new_dir;
}
["exclude_paths"] => {
let paths: Vec<String> = if value.trim_start().starts_with('[') {
serde_json::from_str(value).map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be a JSON array of strings or a single path".to_string(),
))
})?
} else {
vec![value.to_string()]
};
let mut resolved = Vec::with_capacity(paths.len());
for s in paths {
if s.starts_with('~') {
let home = dirs::home_dir().ok_or_else(|| {
LlaError::Config(ConfigErrorKind::InvalidPath(
"Could not determine home directory".to_string(),
))
})?;
resolved.push(home.join(&s[2..]));
} else {
resolved.push(PathBuf::from(s));
}
}
self.exclude_paths = resolved;
}
["default_sort"] => {
if !["name", "size", "date"].contains(&value) {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be one of: name, size, date".to_string(),
)));
}
self.default_sort = value.to_string();
}
["default_format"] => {
let valid_formats = [
"default", "long", "tree", "grid", "git", "timeline", "sizemap", "table",
"fuzzy",
];
if !valid_formats.contains(&value) {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
format!("must be one of: {}", valid_formats.join(", ")),
)));
}
self.default_format = value.to_string();
}
["show_icons"] => {
self.show_icons = value.parse().map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be true or false".to_string(),
))
})?;
}
["include_dirs"] => {
self.include_dirs = value.parse().map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be true or false".to_string(),
))
})?;
}
["default_depth"] => {
if value.to_lowercase() == "null" {
self.default_depth = None;
} else {
let depth = value.parse().map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be a positive number or null".to_string(),
))
})?;
if depth == 0 {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be greater than 0 or null".to_string(),
)));
}
self.default_depth = Some(depth);
}
}
["sort", "dirs_first"] => {
self.sort.dirs_first = value.parse().map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be true or false".to_string(),
))
})?;
}
["sort", "case_sensitive"] => {
self.sort.case_sensitive = value.parse().map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be true or false".to_string(),
))
})?;
}
["sort", "natural"] => {
self.sort.natural = value.parse().map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be true or false".to_string(),
))
})?;
}
["filter", "case_sensitive"] => {
self.filter.case_sensitive = value.parse().map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be true or false".to_string(),
))
})?;
}
["filter", "no_dotfiles"] => {
self.filter.no_dotfiles = value.parse().map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be true or false".to_string(),
))
})?;
}
["filter", "respect_gitignore"] => {
self.filter.respect_gitignore = value.parse().map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be true or false".to_string(),
))
})?;
}
["formatters", "tree", "max_lines"] => {
let max_lines = value.parse().map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be a number".to_string(),
))
})?;
if max_lines > 100_000 {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"should not exceed 100,000".to_string(),
)));
}
self.formatters.tree.max_lines = Some(max_lines);
}
["formatters", "long", "columns"] => {
let columns: Vec<String> = serde_json::from_str(value).map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be a JSON array of strings (e.g., [\"permissions\",\"size\"])"
.to_string(),
))
})?;
self.formatters.long.columns = columns;
}
["formatters", "long", "date_format"] => {
validate_long_date_format(key, value)?;
self.formatters.long.date_format = value.to_string();
}
["formatters", "table", "columns"] => {
let columns: Vec<String> = serde_json::from_str(value).map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be a JSON array of strings (e.g., [\"permissions\",\"name\"])"
.to_string(),
))
})?;
self.formatters.table.columns = columns;
}
["listers", "recursive", "max_entries"] => {
let max_entries = value.parse().map_err(|_| {
LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be a number".to_string(),
))
})?;
if max_entries > 100_000 {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"should not exceed 100,000".to_string(),
)));
}
self.listers.recursive.max_entries = Some(max_entries);
}
["theme"] => {
if let Ok(themes) = crate::theme::list_themes() {
if !themes.contains(&value.to_string()) {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
format!(
"Theme '{}' not found. Available themes: {}",
value,
themes.join(", ")
),
)));
}
}
self.theme = value.to_string();
}
["permission_format"] => {
if value != "symbolic"
&& value != "octal"
&& value != "binary"
&& value != "verbose"
&& value != "compact"
{
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
"must be one of: symbolic, octal, binary, numeric, verbose, compact"
.to_string(),
)));
}
self.permission_format = value.to_string();
}
_ => {
return Err(LlaError::Config(ConfigErrorKind::InvalidValue(
key.to_string(),
format!("unknown configuration key: {}", key),
)));
}
}
self.save(config_path)?;
Ok(())
}
pub fn get_theme(&self) -> Theme {
load_theme(&self.theme).unwrap_or_default()
}
}
impl Default for Config {
fn default() -> Self {
let default_plugins_dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("lla")
.join("plugins");
Config {
default_sort: String::from("name"),
default_format: String::from("default"),
enabled_plugins: vec![],
plugins_dir: default_plugins_dir,
exclude_paths: Vec::new(),
default_depth: Some(3),
show_icons: false,
include_dirs: false,
sort: SortConfig::default(),
filter: FilterConfig::default(),
formatters: FormatterConfig::default(),
listers: ListerConfig::default(),
shortcuts: HashMap::new(),
plugin_aliases: HashMap::new(),
theme: default_theme_name(),
permission_format: default_permission_format(),
}
}
}
fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) {
if let (Some(base_table), TomlValue::Table(overlay_table)) = (base.as_table_mut(), overlay) {
for (key, overlay_value) in overlay_table {
if let Some(existing) = base_table.get_mut(key) {
merge_toml_values(existing, overlay_value);
} else {
base_table.insert(key.clone(), overlay_value.clone());
}
}
} else {
*base = overlay.clone();
}
}
pub fn initialize_config() -> Result<()> {
let config_path = Config::get_config_path();
let config_dir = config_path.parent().ok_or_else(|| {
LlaError::Config(ConfigErrorKind::InvalidPath(format!(
"config path has no parent directory: {}",
config_path.display()
)))
})?;
let themes_dir = config_dir.join("themes");
fs::create_dir_all(config_dir)?;
fs::create_dir_all(&themes_dir)?;
let default_theme_path = themes_dir.join("default.toml");
if !default_theme_path.exists() {
let default_theme_content = include_str!("default.toml");
fs::write(&default_theme_path, default_theme_content)?;
println!("Created default theme at {:?}", default_theme_path);
}
if config_path.exists() {
println!("Config file already exists at {:?}", config_path);
println!("Use `lla config` to view or modify the configuration.");
return Ok(());
}
let config = Config::default();
config.ensure_plugins_dir()?;
fs::write(&config_path, config.generate_config_content())?;
println!("Created default configuration at {:?}", config_path);
Ok(())
}
pub fn handle_config_command(action: Option<ConfigAction>) -> Result<()> {
let config_path = Config::get_config_path();
match action {
Some(ConfigAction::View) | None => view_config(),
Some(ConfigAction::Set(key, value)) => {
let mut config = Config::load(&config_path)?;
config.set_value(&key, &value)?;
println!("Updated {} = {}", key, value);
Ok(())
}
Some(ConfigAction::ShowEffective) => show_effective_config(),
Some(ConfigAction::DiffDefault) => diff_with_defaults(),
}
}
pub fn view_config() -> Result<()> {
let config_path = Config::get_config_path();
let config = Config::load(&config_path)?;
print_config_summary(&config_path, &config);
Ok(())
}
fn show_effective_config() -> Result<()> {
let (layers, config_error) = load_config_layers(None)?;
if let Some(err) = config_error {
println!("⚠ Using default config due to load error: {}", err);
}
if let Some(profile) = &layers.profile_path {
println!("Profile overlay: {}", profile.display());
} else {
println!("Profile overlay: <none>");
}
let rendered = toml::to_string_pretty(&layers.effective)
.map_err(|err| LlaError::Config(ConfigErrorKind::InvalidFormat(err.to_string())))?;
println!("{}", rendered);
Ok(())
}
fn diff_with_defaults() -> Result<()> {
let (layers, config_error) = load_config_layers(None)?;
if let Some(err) = config_error {
println!(
"⚠ Global config could not be loaded cleanly ({}). Diffing may be incomplete.",
err
);
}
let diffs = collect_diff_entries(&layers)?;
if diffs.is_empty() {
println!("Effective configuration matches built-in defaults.");
if let Some(profile) = &layers.profile_path {
println!(
"Profile '{}' is present but does not override defaults.",
profile.display()
);
}
return Ok(());
}
println!("Effective overrides compared to built-in defaults:\n");
for diff in diffs {
println!("{}", diff.key);
println!(" default : {}", diff.default_value);
println!(" current : {}", diff.effective_value);
match diff.source {
DiffSource::Global => println!(" source : global config"),
DiffSource::Profile(path) => println!(" source : profile ({})", path.display()),
}
println!();
}
Ok(())
}
fn print_config_summary(config_path: &Path, config: &Config) {
println!(
"\n{}",
"╔══════════════════════════════════════════════════════════════╗".bright_black()
);
println!(
"{}",
"║ lla configuration summary ║"
.cyan()
.bold()
);
println!(
"{}",
"╚══════════════════════════════════════════════════════════════╝".bright_black()
);
println!(
"{} {}",
"Config file:".bright_black(),
format!("{}", config_path.display()).cyan()
);
println!(
"{}",
"Tip: Use `lla config show-effective` for profile overlays.".bright_black()
);
print_section("Look & feel");
print_row("Theme", config.theme.as_str().cyan());
print_row(
"Default view",
describe_format(&config.default_format).green(),
);
print_row("Permissions", config.permission_format.as_str().green());
print_row(
"Icons",
format_toggle(config.show_icons, "enabled", "disabled"),
);
print_row(
"Include dirs",
format_toggle(config.include_dirs, "include", "files only"),
);
print_row(
"Depth limit",
format_optional_limit(config.default_depth, "levels"),
);
print_section("Sorting & filters");
print_row("Sort order", describe_sort(&config.default_sort).cyan());
print_row(
"Dirs first",
format_toggle(config.sort.dirs_first, "yes", "no"),
);
print_row(
"Sort casing",
format_toggle(config.sort.case_sensitive, "case sensitive", "ignore case"),
);
print_row(
"Natural sort",
format_toggle(config.sort.natural, "natural", "lexical"),
);
print_row(
"Filter casing",
format_toggle(
config.filter.case_sensitive,
"case sensitive",
"ignore case",
),
);
print_row(
"Hide dotfiles",
format_toggle(config.filter.no_dotfiles, "hidden", "show"),
);
print_row(
"Gitignore filter",
format_toggle(
config.filter.respect_gitignore,
"respect .gitignore",
"show all files",
),
);
print_row(
"Filter presets",
format_count(config.filter.presets.len(), "preset"),
);
print_section("Formatter defaults");
print_row(
"Long columns",
format_column_preview(&config.formatters.long.columns),
);
print_row(
"Long relative",
format_toggle(
config.formatters.long.relative_dates,
"relative",
"absolute",
),
);
print_row("Long date format", &config.formatters.long.date_format);
print_row(
"Hide group",
format_toggle(config.formatters.long.hide_group, "hidden", "visible"),
);
print_row(
"Tree max lines",
format_optional_limit(config.formatters.tree.max_lines, "lines"),
);
print_row(
"Grid width cap",
format_plain_number(config.formatters.grid.max_width, "cols"),
);
print_row(
"Grid ignores width",
format_toggle(config.formatters.grid.ignore_width, "ignore", "respect"),
);
print_section("Plugins & automation");
print_row(
"Plugin dir",
format!("{}", config.plugins_dir.display()).yellow(),
);
print_row("Plugins", format_plugin_list(&config.enabled_plugins));
print_row(
"Shortcuts",
format_count(config.shortcuts.len(), "shortcut"),
);
print_row(
"Aliases",
format_count(config.plugin_aliases.len(), "alias"),
);
print_section("Safety & limits");
print_row(
"Recursive guard",
format_optional_limit(config.listers.recursive.max_entries, "entries"),
);
print_row(
"Exclude paths",
format_count(config.exclude_paths.len(), "path"),
);
println!(
"\n{}",
"Need the raw data? Try `lla config show-effective` or `lla config diff --default`."
.bright_black()
);
}
fn describe_format(format: &str) -> &str {
match format {
"tree" => "Tree (hierarchical)",
"long" => "Long (detailed)",
"grid" => "Grid (compact)",
"table" => "Table (columns)",
"timeline" => "Timeline",
"git" => "Git status",
"sizemap" => "Size map",
"fuzzy" => "Fuzzy finder",
_ => "Recommended default",
}
}
fn describe_sort(sort: &str) -> &str {
match sort {
"size" => "Size (small → large)",
"date" => "Date (newest first)",
_ => "Name (A→Z)",
}
}
fn print_section(title: &str) {
println!("\n{}", title.bold());
println!("{}", "─".repeat(title.len()).bright_black());
}
fn print_row(label: &str, value: impl Display) {
let key = format!("{:<16}", label);
println!(" {} {}", key.bold(), value);
}
fn format_toggle(value: bool, on_label: &str, off_label: &str) -> ColoredString {
if value {
on_label.green().bold()
} else {
off_label.bright_black()
}
}
fn format_optional_limit(value: Option<usize>, noun: &str) -> ColoredString {
match value {
Some(v) => format!("{} {}", v, noun).bold(),
None => "no limit".bright_black(),
}
}
fn format_plain_number(value: usize, noun: &str) -> ColoredString {
format!("{} {}", value, noun).bold()
}
fn format_plugin_list(plugins: &[String]) -> ColoredString {
if plugins.is_empty() {
"(none enabled)".bright_black()
} else {
let preview = format_preview_list(plugins, 5);
preview.as_str().magenta()
}
}
fn format_column_preview(columns: &[String]) -> ColoredString {
if columns.is_empty() {
"(none configured)".bright_black()
} else {
let preview = format_preview_list(columns, 6);
preview.as_str().purple()
}
}
fn format_count(count: usize, noun: &str) -> ColoredString {
if count == 0 {
"none".bright_black()
} else {
let plural = if count == 1 {
noun.to_string()
} else {
format!("{}s", noun)
};
format!("{} {}", count, plural).bold()
}
}
fn format_preview_list(items: &[String], limit: usize) -> String {
if items.is_empty() {
return String::new();
}
let show = items.len().min(limit);
let mut preview = items
.iter()
.take(show)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let remaining = items.len().saturating_sub(show);
if remaining > 0 {
preview.push_str(&format!(" … (+{} more)", remaining));
}
preview
}
struct DiffEntry {
key: String,
default_value: String,
effective_value: String,
source: DiffSource,
}
enum DiffSource {
Global,
Profile(PathBuf),
}
fn collect_diff_entries(layers: &ConfigLayers) -> Result<Vec<DiffEntry>> {
let default_map = flatten_config(&layers.default)?;
let global_map = flatten_config(&layers.global)?;
let effective_map = flatten_config(&layers.effective)?;
let mut entries = Vec::new();
for (key, effective_value) in &effective_map {
let default_value = default_map.get(key);
if default_value == Some(effective_value) {
continue;
}
let global_value = global_map.get(key);
let source = if let Some(profile) = &layers.profile_path {
if global_value != Some(effective_value) {
DiffSource::Profile(profile.clone())
} else {
DiffSource::Global
}
} else {
DiffSource::Global
};
entries.push(DiffEntry {
key: key.clone(),
default_value: option_value_to_string(default_value),
effective_value: json_value_to_string(effective_value),
source,
});
}
Ok(entries)
}
fn flatten_config(config: &Config) -> Result<BTreeMap<String, JsonValue>> {
let root = serde_json::to_value(config)?;
let mut map = BTreeMap::new();
flatten_json("", &root, &mut map);
Ok(map)
}
fn flatten_json(prefix: &str, value: &JsonValue, map: &mut BTreeMap<String, JsonValue>) {
match value {
JsonValue::Object(obj) => {
for (key, child) in obj {
let next_prefix = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
flatten_json(&next_prefix, child, map);
}
}
_ => {
if !prefix.is_empty() {
map.insert(prefix.to_string(), value.clone());
}
}
}
}
fn option_value_to_string(value: Option<&JsonValue>) -> String {
value
.map(json_value_to_string)
.unwrap_or_else(|| "<unset>".to_string())
}
fn json_value_to_string(value: &JsonValue) -> String {
match value {
JsonValue::String(s) => format!("\"{}\"", s),
JsonValue::Number(n) => n.to_string(),
JsonValue::Bool(b) => b.to_string(),
JsonValue::Null => "null".to_string(),
JsonValue::Array(arr) => {
let items: Vec<String> = arr.iter().map(json_value_to_string).collect();
format!("[{}]", items.join(", "))
}
JsonValue::Object(obj) => {
let items: Vec<String> = obj
.iter()
.map(|(k, v)| format!("{}: {}", k, json_value_to_string(v)))
.collect();
format!("{{{}}}", items.join(", "))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn long_date_format_defaults_to_existing_output_shape() {
let config = Config::default();
assert_eq!(config.formatters.long.date_format, DEFAULT_LONG_DATE_FORMAT);
}
#[test]
fn generated_config_includes_long_date_format() {
let content = Config::default().generate_config_content();
assert!(content.contains("date_format = \"%b %d %H:%M\""));
assert!(content.contains("%Y-%m-%d %H:%M"));
}
#[test]
fn config_set_updates_long_date_format() {
let config_dir = tempfile::tempdir().unwrap();
let plugins_dir = config_dir.path().join("plugins");
let config_path = config_dir.path().join("config.toml");
fs::create_dir(&plugins_dir).unwrap();
let mut config = Config::default();
config.plugins_dir = plugins_dir;
config
.set_value_at_path(
"formatters.long.date_format",
"%Y-%m-%d %H:%M",
&config_path,
)
.unwrap();
assert_eq!(config.formatters.long.date_format, "%Y-%m-%d %H:%M");
assert!(config_path.is_file());
}
#[test]
fn loading_existing_config_creates_missing_plugins_dir() {
let config_dir = tempfile::tempdir().unwrap();
let plugins_dir = config_dir.path().join("missing-plugins");
let config_path = config_dir.path().join("config.toml");
let mut config = Config::default();
config.plugins_dir = plugins_dir.clone();
fs::write(&config_path, config.generate_config_content()).unwrap();
let loaded = Config::load(&config_path).unwrap();
assert_eq!(loaded.plugins_dir, plugins_dir);
assert!(loaded.plugins_dir.is_dir());
}
#[test]
fn long_date_format_rejects_empty_and_invalid_formats() {
assert!(validate_long_date_format("formatters.long.date_format", "").is_err());
assert!(validate_long_date_format("formatters.long.date_format", "%Q").is_err());
}
#[cfg(feature = "dynamic-plugins")]
#[test]
fn dynamic_build_rejects_missing_enabled_plugins() {
let config_dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.plugins_dir = config_dir.path().join("plugins");
fs::create_dir(&config.plugins_dir).unwrap();
config.enabled_plugins = vec!["missing_plugin".to_string()];
assert!(config.validate().is_err());
}
#[cfg(not(feature = "dynamic-plugins"))]
#[test]
fn static_build_ignores_enabled_plugins_during_validation() {
let config_dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.plugins_dir = config_dir.path().join("plugins");
fs::create_dir(&config.plugins_dir).unwrap();
config.enabled_plugins = vec!["missing_plugin".to_string()];
assert!(config.validate().is_ok());
}
}