use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use log::*;
use super::error::{ConfigError, IoResultExt};
use super::schema::{ACCEPTED_VERSIONS, Config};
pub fn find_config_path() -> Option<PathBuf> {
if let Ok(path) = env::var("LX_CONFIG") {
let p = PathBuf::from(&path);
if p.is_file() {
debug!("Config from LX_CONFIG: {}", p.display());
return Some(p);
}
debug!("LX_CONFIG={path}: not a file, no config");
return None;
}
if let Some(home) = home_dir() {
let p = home.join(".lxconfig.toml");
if p.is_file() {
debug!("Config from home dir: {}", p.display());
return Some(p);
}
}
let xdg = env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home_dir().map(|h| h.join(".config")).unwrap_or_default());
let p = xdg.join("lx").join("config.toml");
if p.is_file() {
debug!("Config from XDG: {}", p.display());
return Some(p);
}
#[cfg(target_os = "macos")]
if let Some(home) = home_dir() {
let p = home.join("Library/Application Support/lx/config.toml");
if p.is_file() {
debug!("Config from macOS Library: {}", p.display());
return Some(p);
}
}
None
}
pub(super) fn find_drop_in_dir(main_config: Option<&Path>) -> Option<PathBuf> {
if let Some(config_path) = main_config
&& let Some(parent) = config_path.parent()
{
let d = parent.join("conf.d");
if d.is_dir() {
return Some(d);
}
}
let xdg = env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home_dir().map(|h| h.join(".config")).unwrap_or_default());
let d = xdg.join("lx").join("conf.d");
if d.is_dir() {
return Some(d);
}
#[cfg(target_os = "macos")]
if let Some(home) = home_dir() {
let d = home.join("Library/Application Support/lx/conf.d");
if d.is_dir() {
return Some(d);
}
}
None
}
fn load_drop_ins(dir: &Path) -> Vec<(PathBuf, Config)> {
let mut entries: Vec<PathBuf> = match fs::read_dir(dir) {
Ok(rd) => rd
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "toml"))
.collect(),
Err(e) => {
warn!("conf.d: failed to read {}: {e}", dir.display());
return Vec::new();
}
};
entries.sort();
let mut fragments = Vec::new();
for path in entries {
match fs::read_to_string(&path) {
Ok(contents) => match toml::from_str::<Config>(&contents) {
Ok(cfg) => {
debug!("conf.d: loaded {}", path.display());
fragments.push((path, cfg));
}
Err(e) => {
warn!("conf.d: parse error in {}: {e}", path.display());
}
},
Err(e) => {
warn!("conf.d: failed to read {}: {e}", path.display());
}
}
}
fragments
}
pub(super) fn try_load_config() -> Result<Option<Config>, ConfigError> {
let config_path = find_config_path();
let mut config = if let Some(ref path) = config_path {
let contents = fs::read_to_string(path).with_path(path)?;
let version = detect_config_version(&contents);
if !ACCEPTED_VERSIONS.contains(&version) {
return Err(ConfigError::NeedsUpgrade {
path: path.clone(),
version: version.to_string(),
});
}
let cfg: Config = toml::from_str(&contents).map_err(|source| ConfigError::Parse {
path: path.clone(),
source,
})?;
if version == "0.3" {
let has_when = cfg.personality.values().any(|p| !p.when.is_empty());
if has_when {
eprintln!("lx: config has [[personality.*.when]] blocks but version is \"0.3\".");
eprintln!(" Change version to \"0.4\" to enable conditional config.");
}
}
info!("Loaded config from {}", path.display());
Some(cfg)
} else {
None
};
if let Some(drop_in_dir) = find_drop_in_dir(config_path.as_deref()) {
let fragments = load_drop_ins(&drop_in_dir);
if !fragments.is_empty() {
let config = config.get_or_insert_with(Config::default);
for (path, fragment) in fragments {
config.drop_in_paths.push(path);
config.merge(fragment);
}
}
}
Ok(config)
}
pub(super) fn detect_config_version(contents: &str) -> &str {
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with("version") && trimmed.contains('=') {
if let Some(val) = trimmed.split('=').nth(1) {
let val = val.trim().trim_matches('"');
return match val {
"0.2" => "0.2",
"0.3" => "0.3",
"0.4" => "0.4",
_ => val, };
}
}
}
"0.1" }
pub fn default_config_toml() -> &'static str {
include_str!("../../lxconfig.default.toml")
}
pub(super) fn inject_auto_select_blocks(contents: &str) -> String {
const BLOCKS: &str = "\n\
## Auto-select a richer theme on capable terminals (added by\n\
## lx --upgrade-config to 0.6). Delete or edit to opt out.\n\
\n\
[[personality.default.when]]\n\
env.TERM = \"*-256color\"\n\
theme = \"lx-256\"\n\
\n\
[[personality.default.when]]\n\
env.COLORTERM = [\"truecolor\", \"24bit\"]\n\
theme = \"lx-24bit\"\n\
\n";
let lines: Vec<&str> = contents.lines().collect();
let mut in_default = false;
let mut insert_after: Option<usize> = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("[personality.default]") {
in_default = true;
continue;
}
if in_default && (trimmed.starts_with('[') && !trimmed.starts_with("[[")) {
insert_after = Some(i);
break;
}
if in_default && trimmed.starts_with("[[") {
insert_after = Some(i);
break;
}
}
let insert_at = match insert_after {
Some(i) => i,
None if in_default => lines.len(),
None => return contents.to_string(), };
let mut result = String::new();
for (i, line) in lines.iter().enumerate() {
if i == insert_at {
result.push_str(BLOCKS);
}
result.push_str(line);
result.push('\n');
}
if insert_at == lines.len() {
result.push_str(BLOCKS);
}
if !contents.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
result
}
pub(super) fn match_string(actual: &str, expected: &str) -> bool {
if expected.contains(['*', '?', '[']) {
glob::Pattern::new(expected).is_ok_and(|pat| pat.matches(actual))
} else {
actual == expected
}
}
pub(super) fn home_dir() -> Option<PathBuf> {
env::var_os("HOME").map(PathBuf::from)
}
#[cfg(test)]
mod when_match_test {
use super::match_string;
#[test]
fn literal_match() {
assert!(match_string("truecolor", "truecolor"));
assert!(!match_string("truecolor", "24bit"));
}
#[test]
fn empty_actual_matches_empty_expected() {
assert!(match_string("", ""));
}
#[test]
fn glob_star_suffix() {
assert!(match_string("xterm-256color", "*-256color"));
assert!(match_string("screen-256color", "*-256color"));
assert!(match_string("rxvt-unicode-256color", "*-256color"));
assert!(!match_string("xterm", "*-256color"));
assert!(!match_string("xterm-direct", "*-256color"));
}
#[test]
fn glob_question_mark() {
assert!(match_string("foo", "fo?"));
assert!(!match_string("foobar", "fo?"));
}
#[test]
fn glob_bracket_range() {
assert!(match_string("file1", "file[0-9]"));
assert!(!match_string("filea", "file[0-9]"));
}
#[test]
fn invalid_glob_falls_back_to_no_match() {
assert!(!match_string("foo[bar", "foo[bar"));
}
}