use std::collections::HashMap;
use std::path::PathBuf;
use log::*;
use super::error::{ConfigError, IoResultExt};
use super::load::{find_config_path, find_drop_in_dir};
use super::schema::{ConditionalOverride, PersonalityDef};
use super::store::config;
#[derive(Debug, Clone)]
pub struct ChainLink {
pub name: String,
pub source: PersonalitySource,
pub def: PersonalityDef,
pub shadowed_builtin: Option<PersonalityDef>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PersonalitySource {
Builtin,
Config,
ConfigOverridesBuiltin,
}
#[derive(Debug, Clone)]
pub struct ResolvedPersonality {
pub def: PersonalityDef,
pub chain: Vec<ChainLink>,
}
pub fn resolve_personality(name: &str) -> Result<Option<PersonalityDef>, ConfigError> {
Ok(resolve_personality_full(name)?.map(|r| r.def))
}
pub fn resolve_personality_full(name: &str) -> Result<Option<ResolvedPersonality>, ConfigError> {
let mut defs: Vec<(String, PersonalityDef)> = Vec::new();
let mut visited: Vec<String> = Vec::new();
let mut current = Some(name.to_string());
while let Some(ref pname) = current {
if visited.contains(pname) {
visited.push(pname.clone());
let chain_str = visited.join(" \u{2192} ");
return Err(ConfigError::InheritanceCycle { chain: chain_str });
}
visited.push(pname.clone());
let Some(def) = lookup_personality(pname) else {
if defs.is_empty() {
return Ok(None); }
return Err(ConfigError::MissingParent {
child: visited[visited.len() - 2].clone(),
parent: pname.clone(),
});
};
let next = def.inherits.clone();
defs.push((pname.clone(), def));
current = next;
}
let cfg = config();
let chain: Vec<ChainLink> = defs
.iter()
.map(|(pname, def)| {
let in_config = cfg.is_some_and(|c| c.personality.contains_key(pname));
let in_builtin = compiled_personality(pname).is_some();
let source = match (in_config, in_builtin) {
(true, true) => PersonalitySource::ConfigOverridesBuiltin,
(true, false) => PersonalitySource::Config,
_ => PersonalitySource::Builtin,
};
let shadowed_builtin = if source == PersonalitySource::ConfigOverridesBuiltin {
compiled_personality(pname)
} else {
None
};
ChainLink {
name: pname.clone(),
source,
def: def.clone(),
shadowed_builtin,
}
})
.collect();
let mut effective = PersonalityDef::default();
for (_name, def) in defs.into_iter().rev() {
if def.format.is_some() {
effective.format = def.format;
}
if def.columns.is_some() {
effective.columns = def.columns;
}
for (key, value) in def.settings {
effective.settings.insert(key, value);
}
effective.when.extend(def.when);
}
for cond in &effective.when {
if cond.matches() {
debug!(
"conditional override matched: env = {:?}, platform = {:?}",
cond.env, cond.platform
);
for (key, value) in &cond.settings {
effective.settings.insert(key.clone(), value.clone());
}
}
}
Ok(Some(ResolvedPersonality {
def: effective,
chain,
}))
}
fn lookup_personality(name: &str) -> Option<PersonalityDef> {
if let Some(cfg) = config()
&& let Some(p) = cfg.personality.get(name)
{
return Some(p.clone());
}
compiled_personality(name)
}
fn compiled_personality(name: &str) -> Option<PersonalityDef> {
use toml::Value::{Boolean, String as Str};
match name {
"default" => Some(PersonalityDef {
description: Some(
"Shared base; auto-selects a richer theme on capable terminals".into(),
),
settings: HashMap::from([
("gradient".into(), Str("all".into())),
("group-dirs".into(), Str("none".into())),
("icons".into(), Str("never".into())),
("classify".into(), Str("never".into())),
("theme".into(), Str("exa".into())),
("xattr-indicator".into(), Boolean(true)),
]),
when: vec![
ConditionalOverride {
env: HashMap::from([("TERM".into(), toml::Value::String("*-256color".into()))]),
platform: None,
settings: HashMap::from([(
"theme".into(),
toml::Value::String("lx-256".into()),
)]),
},
ConditionalOverride {
env: HashMap::from([(
"COLORTERM".into(),
toml::Value::Array(vec![
toml::Value::String("truecolor".into()),
toml::Value::String("24bit".into()),
]),
)]),
platform: None,
settings: HashMap::from([(
"theme".into(),
toml::Value::String("lx-24bit".into()),
)]),
},
ConditionalOverride {
env: HashMap::new(),
platform: Some(toml::Value::String("macos".into())),
settings: HashMap::from([("xattr-indicator".into(), Boolean(false))]),
},
],
..Default::default()
}),
"lx" => Some(PersonalityDef {
description: Some("Default for the `lx` binary; inherits `default`".into()),
inherits: Some("default".into()),
..Default::default()
}),
"ll" => Some(PersonalityDef {
description: Some("Two-tier long view; directories grouped first".into()),
inherits: Some("lx".into()),
format: Some("long2".into()),
settings: HashMap::from([("group-dirs".into(), Str("first".into()))]),
..Default::default()
}),
"lll" => Some(PersonalityDef {
description: Some("Three-tier long view with header and ISO timestamps".into()),
inherits: Some("lx".into()),
format: Some("long3".into()),
settings: HashMap::from([
("group-dirs".into(), Str("first".into())),
("header".into(), Boolean(true)),
("time-style".into(), Str("long-iso".into())),
]),
..Default::default()
}),
"la" => Some(PersonalityDef {
description: Some("Like `ll`, but includes hidden files".into()),
inherits: Some("ll".into()),
settings: HashMap::from([("all".into(), Boolean(true))]),
..Default::default()
}),
"tree" => Some(PersonalityDef {
description: Some("Long-view tree; directories grouped first".into()),
inherits: Some("default".into()),
format: Some("long2".into()),
settings: HashMap::from([
("tree".into(), Boolean(true)),
("group-dirs".into(), Str("first".into())),
]),
..Default::default()
}),
"ls" => Some(PersonalityDef {
description: Some(
"Plain `ls`-style: across-the-rows grid, no colours, no decorations".into(),
),
settings: HashMap::from([
("grid".into(), Boolean(true)),
("across".into(), Boolean(true)),
]),
..Default::default()
}),
_ => None,
}
}
fn format_toml_kv(key: &str, value: &toml::Value) -> String {
match value {
toml::Value::String(s) => format!("{key} = \"{s}\""),
toml::Value::Boolean(b) => format!("{key} = {b}"),
toml::Value::Integer(i) => format!("{key} = {i}"),
toml::Value::Float(f) => format!("{key} = {f}"),
toml::Value::Array(arr) => {
let items: Vec<String> = arr
.iter()
.map(|v| match v {
toml::Value::String(s) => format!("\"{s}\""),
other => other.to_string(),
})
.collect();
format!("{key} = [{}]", items.join(", "))
}
other => format!("{key} = {other}"),
}
}
const COMPILED_PERSONALITIES: &[&str] = &["default", "lx", "ll", "lll", "la", "tree", "ls"];
pub fn is_compiled_personality(name: &str) -> bool {
COMPILED_PERSONALITIES.contains(&name)
}
pub fn personality_description(name: &str) -> Option<String> {
lookup_personality(name).and_then(|d| d.description)
}
pub fn all_personality_names() -> Vec<String> {
let mut names: Vec<String> = COMPILED_PERSONALITIES.iter().map(|s| (*s).into()).collect();
if let Some(cfg) = config() {
for name in cfg.personality.keys() {
if !names.iter().any(|n| n == name) {
names.push(name.clone());
}
}
}
names.sort();
let mut ordered = Vec::with_capacity(names.len());
let mut remaining = names;
while !remaining.is_empty() {
let (ready, rest): (Vec<_>, Vec<_>) = remaining.into_iter().partition(|name| {
lookup_personality(name)
.and_then(|def| def.inherits)
.is_none_or(|parent| ordered.contains(&parent))
});
if ready.is_empty() {
ordered.extend(rest);
break;
}
ordered.extend(ready);
remaining = rest;
}
ordered
}
fn format_personality_toml(name: &str) -> Option<String> {
let def = lookup_personality(name)?;
let mut lines = vec![format!("[personality.{name}]")];
if let Some(ref description) = def.description {
lines.push(format!("description = \"{description}\""));
}
if let Some(ref inherits) = def.inherits {
lines.push(format!("inherits = \"{inherits}\""));
}
if let Some(ref format) = def.format {
lines.push(format!("format = \"{format}\""));
}
if let Some(ref columns) = def.columns {
let entries: Vec<String> = columns
.to_csv()
.split(',')
.map(|s| format!("\"{}\"", s.trim()))
.collect();
lines.push(format!("columns = [{}]", entries.join(", ")));
}
let mut keys: Vec<_> = def.settings.keys().collect();
keys.sort();
for key in keys {
lines.push(format_toml_kv(key, &def.settings[key]));
}
for cond in &def.when {
lines.push(String::new());
lines.push(format!("[[personality.{name}.when]]"));
if let Some(p) = &cond.platform {
lines.push(format_toml_kv("platform", p));
}
let mut env_keys: Vec<_> = cond.env.keys().collect();
env_keys.sort();
for ek in env_keys {
lines.push(format_toml_kv(&format!("env.{ek}"), &cond.env[ek]));
}
let mut setting_keys: Vec<_> = cond.settings.keys().collect();
setting_keys.sort();
for sk in setting_keys {
lines.push(format_toml_kv(sk, &cond.settings[sk]));
}
}
Some(lines.join("\n"))
}
pub fn dump_personality(name: &str) -> Result<(), ConfigError> {
if let Some(toml) = format_personality_toml(name) {
println!("{toml}");
Ok(())
} else {
Err(ConfigError::NotFound {
kind: "personality",
kind_plural: "personalities",
name: name.to_string(),
candidates: all_personality_names().join(", "),
})
}
}
pub fn dump_personality_all() {
let names = all_personality_names();
let mut first = true;
for name in &names {
if let Some(toml) = format_personality_toml(name) {
if !first {
println!();
}
println!("{toml}");
first = false;
}
}
}
fn build_personality_toml(
name: Option<&str>,
inherits: Option<&str>,
settings: &HashMap<String, toml::Value>,
flag_name: &str,
) -> String {
use chrono::Local;
let resolved_name = name.unwrap_or("UNNAMED");
let prefix = if name.is_some() { "" } else { "# " };
let mut lines = Vec::new();
lines.push(format!(
"# Generated by lx --{flag_name} on {}",
Local::now().format("%Y-%m-%d")
));
if name.is_none() {
lines.push(String::from(
"# Anonymous preview — uncomment, rename, or redirect into a [personality.NAME] block.",
));
}
lines.push(String::new());
lines.push(format!("{prefix}[personality.{resolved_name}]"));
if let Some(parent) = inherits {
lines.push(format!("{prefix}inherits = \"{parent}\""));
}
let mut keys: Vec<_> = settings.keys().collect();
keys.sort();
for key in keys {
lines.push(format!("{prefix}{}", format_toml_kv(key, &settings[key])));
}
lines.push(String::new());
lines.join("\n")
}
pub fn show_personality_as(
name: Option<&str>,
inherits: Option<&str>,
settings: &HashMap<String, toml::Value>,
) {
print!(
"{}",
build_personality_toml(name, inherits, settings, "show-as")
);
}
pub fn save_personality_as(
name: &str,
inherits: Option<&str>,
settings: &HashMap<String, toml::Value>,
) -> Result<(), ConfigError> {
let toml_content = build_personality_toml(Some(name), inherits, settings, "save-as");
let conf_dir = find_drop_in_dir(find_config_path().as_deref()).unwrap_or_else(|| {
let xdg = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").expect("HOME not set");
PathBuf::from(home).join(".config")
});
xdg.join("lx").join("conf.d")
});
std::fs::create_dir_all(&conf_dir).with_path(&conf_dir)?;
let file_path = conf_dir.join(format!("{name}.toml"));
if file_path.exists() {
let backup = file_path.with_extension("toml.bak");
std::fs::rename(&file_path, &backup).with_path(&file_path)?;
eprintln!(
"lx: backed up {} → {}",
file_path.display(),
backup.display()
);
}
std::fs::write(&file_path, &toml_content).with_path(&file_path)?;
eprintln!("lx: saved personality '{name}' to {}", file_path.display());
Ok(())
}