mod support;
use predicates::prelude::*;
use std::fs;
use support::lx_no_colour;
use tempfile::tempdir;
fn lx_with_theme(config_content: &str) -> (tempfile::TempDir, assert_cmd::Command) {
let dir = tempdir().expect("failed to create tempdir");
let config_path = dir.path().join("config.toml");
fs::write(&config_path, config_content).unwrap();
let mut cmd = assert_cmd::Command::cargo_bin("lx").expect("binary lx not found");
cmd.env("LX_CONFIG", config_path)
.env("HOME", "/nonexistent")
.env_remove("LS_COLORS")
.arg("--colour=always");
(dir, cmd)
}
#[test]
fn theme_directory_colour() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.test]
directory = "bold red"
"#,
);
let work = tempdir().expect("failed to create workdir");
fs::create_dir(work.path().join("mydir")).unwrap();
cmd.args(["--theme=test", "-1"])
.arg(work.path())
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;31m"));
}
#[test]
fn theme_date_colour() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.test]
date = "bold cyan"
"#,
);
cmd.args(["--theme=test", "-l", "Cargo.toml"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;36m"));
}
#[test]
fn theme_x11_colour() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.test]
date = "tomato"
"#,
);
cmd.args(["--theme=test", "-l", "Cargo.toml"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[38;2;255;99;71m"));
}
#[test]
fn theme_hex_colour() {
let (_dir, mut cmd) = lx_with_theme("version = \"0.3\"\n[theme.test]\ndate = \"#ff8700\"\n");
cmd.args(["--theme=test", "-l", "Cargo.toml"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[38;2;255;135;0m"));
}
#[test]
fn theme_extension_colour() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.test]
use-style = "myexts"
[style.myexts]
"*.txt" = "bold magenta"
"#,
);
let work = tempdir().expect("failed to create workdir");
fs::write(work.path().join("readme.txt"), "").unwrap();
cmd.args(["--theme=test", "-1"])
.arg(work.path())
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;35m"));
}
#[test]
fn theme_filename_colour() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.test]
use-style = "mynames"
[style.mynames]
Makefile = "bold underline yellow"
"#,
);
let work = tempdir().expect("failed to create workdir");
fs::write(work.path().join("Makefile"), "").unwrap();
cmd.args(["--theme=test", "-1"])
.arg(work.path())
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;4;33m"));
}
#[test]
fn theme_via_personality() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[personality.lx]
theme = "ocean"
[theme.ocean]
date = "bold cyan"
"#,
);
cmd.args(["-l", "Cargo.toml"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;36m"));
}
#[test]
fn theme_inherited_through_personality() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[personality.default]
theme = "ocean"
[personality.myview]
inherits = "default"
format = "long"
[theme.ocean]
date = "bold cyan"
"#,
);
cmd.args(["-pmyview", "Cargo.toml"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;36m"));
}
#[test]
fn theme_cli_overrides_personality() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[personality.lx]
theme = "ocean"
[theme.ocean]
date = "bold cyan"
[theme.warm]
date = "bold red"
"#,
);
cmd.args(["--theme=warm", "-l", "Cargo.toml"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;31m"));
}
#[test]
fn style_class_reference() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[class]
testclass = ["*.xyz"]
[theme.test]
inherits = "exa"
use-style = "mystyle"
[style.mystyle]
class.testclass = "bold magenta"
"#,
);
let work = tempdir().expect("failed to create workdir");
fs::write(work.path().join("data.xyz"), "").unwrap();
cmd.args(["--theme=test", "-1"])
.arg(work.path())
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;35m"));
}
#[test]
fn style_class_overrides_exa_default() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.test]
inherits = "exa"
use-style = "custom"
[style.custom]
class.compressed = "bold cyan"
"#,
);
let work = tempdir().expect("failed to create workdir");
fs::write(work.path().join("archive.zip"), "").unwrap();
cmd.args(["--theme=test", "-1"])
.arg(work.path())
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;36m"));
}
#[test]
fn style_quoted_pattern_and_class() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[class]
data = ["*.csv"]
[theme.test]
inherits = "exa"
use-style = "mixed"
[style.mixed]
class.data = "bold green"
"Makefile" = "bold red"
"#,
);
let work = tempdir().expect("failed to create workdir");
fs::write(work.path().join("results.csv"), "").unwrap();
fs::write(work.path().join("Makefile"), "").unwrap();
cmd.args(["--theme=test", "-1"])
.arg(work.path())
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;32m")) .stdout(predicate::str::contains("\x1b[1;31m")); }
#[test]
fn user_class_overrides_compiled_in() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[class]
compressed = ["*.myarc"]
[theme.test]
inherits = "exa"
use-style = "exa"
"#,
);
let work = tempdir().expect("failed to create workdir");
fs::write(work.path().join("data.myarc"), "").unwrap();
fs::write(work.path().join("stuff.zip"), "").unwrap();
cmd.args(["--theme=test", "-1"])
.arg(work.path())
.assert()
.success()
.stdout(predicate::str::contains("\x1b[31m"));
}
#[test]
fn theme_inherits_exa() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.custom]
inherits = "exa"
date = "bold red"
"#,
);
cmd.args(["--theme=custom", "-l", "src"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;34m"))
.stdout(predicate::str::contains("\x1b[1;31m"));
}
#[test]
fn theme_without_inherits_is_blank() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.bare]
date = "bold red"
"#,
);
cmd.args(["--theme=bare", "-l", "src"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;31m"))
.stdout(predicate::str::contains("\x1b[1;34m").not());
}
#[test]
fn theme_inherits_custom() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.base]
inherits = "exa"
date = "bold cyan"
[theme.child]
inherits = "base"
directory = "bold red"
"#,
);
cmd.args(["--theme=child", "-l", "src"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;31m")) .stdout(predicate::str::contains("\x1b[1;36m")); }
#[test]
fn theme_inheritance_cycle_detected() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.a]
inherits = "b"
[theme.b]
inherits = "a"
"#,
);
cmd.args(["--theme=a", "-1", "Cargo.toml"])
.assert()
.failure()
.code(3)
.stderr(predicate::str::contains("theme inheritance cycle"));
}
#[test]
fn no_theme_works() {
lx_no_colour()
.args(["-1", "Cargo.toml"])
.assert()
.success()
.stdout(predicate::str::contains("Cargo.toml"));
}
fn lx_clean() -> assert_cmd::Command {
let mut cmd = assert_cmd::Command::cargo_bin("lx").expect("binary lx not found");
cmd.env("LX_CONFIG", "/nonexistent")
.env("HOME", "/nonexistent")
.env_remove("LS_COLORS")
.env_remove("TERM")
.env_remove("COLORTERM");
cmd
}
#[test]
fn default_theme_produces_colour() {
lx_clean()
.arg("--colour=always")
.arg("--theme=exa")
.args(["-l", "src"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;34m")) .stdout(predicate::str::contains("\x1b[34m")); }
#[test]
fn lx_256_theme_produces_256_colour() {
lx_clean()
.arg("--colour=always")
.arg("--theme=lx-256")
.args(["-l", "src"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;38;5;33m"));
}
#[test]
fn lx_24bit_theme_produces_truecolour() {
lx_clean()
.arg("--colour=always")
.arg("--theme=lx-24bit")
.args(["-l", "src"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;38;2;59;142;216m"));
}
#[test]
fn auto_selection_picks_exa_with_no_term() {
lx_clean()
.arg("--colour=always")
.args(["-l", "src"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;34m")); }
#[test]
fn auto_selection_picks_lx_256_for_256color_term() {
lx_clean()
.env("TERM", "xterm-256color")
.arg("--colour=always")
.args(["-l", "src"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;38;5;33m")); }
#[test]
fn auto_selection_picks_lx_24bit_for_truecolor_colorterm() {
lx_clean()
.env("TERM", "xterm-256color")
.env("COLORTERM", "truecolor")
.arg("--colour=always")
.args(["-l", "src"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;38;2;59;142;216m")); }
#[test]
fn auto_selection_accepts_24bit_colorterm_value() {
lx_clean()
.env("COLORTERM", "24bit")
.arg("--colour=always")
.args(["-l", "src"])
.assert()
.success()
.stdout(predicate::str::contains("\x1b[1;38;2;59;142;216m"));
}
#[test]
fn default_theme_colours_filetypes() {
let mut cmd = assert_cmd::Command::cargo_bin("lx").expect("binary lx not found");
cmd.env("LX_CONFIG", "/nonexistent")
.env("HOME", "/nonexistent")
.env_remove("LS_COLORS")
.arg("--colour=always");
let work = tempdir().expect("failed to create workdir");
fs::write(work.path().join("archive.zip"), "").unwrap();
cmd.args(["-1"])
.arg(work.path())
.assert()
.success()
.stdout(predicate::str::contains("\x1b[31m"));
}
#[test]
fn init_config_preserves_default_colours() {
let dir = tempdir().expect("failed to create tempdir");
let mut init = assert_cmd::Command::cargo_bin("lx").expect("binary lx not found");
init.args(["--init-config"])
.env("HOME", dir.path())
.env("LX_CONFIG", "/nonexistent")
.assert()
.success();
let config_path = dir.path().join(".lxconfig.toml");
assert!(config_path.exists());
fn common(cmd: &mut assert_cmd::Command, dir: &std::path::Path) {
cmd.env("HOME", dir)
.env_remove("LS_COLORS")
.arg("--colour=always")
.args(["-l", "src"]);
}
let mut no_cfg = assert_cmd::Command::cargo_bin("lx").expect("binary lx not found");
no_cfg.env("LX_CONFIG", "/nonexistent");
common(&mut no_cfg, dir.path());
let no_cfg_out = no_cfg.assert().success().get_output().stdout.clone();
let mut with_cfg = assert_cmd::Command::cargo_bin("lx").expect("binary lx not found");
with_cfg.env("LX_CONFIG", &config_path);
common(&mut with_cfg, dir.path());
let with_cfg_out = with_cfg.assert().success().get_output().stdout.clone();
assert_eq!(
no_cfg_out, with_cfg_out,
"--init-config changed behaviour (invariant #2 violation)"
);
}
#[test]
fn per_column_date_keys_round_trip_through_dump_theme() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.full-fat]
inherits = "exa"
date = "white"
date-now = "bright cyan"
date-modified = "bright green"
date-modified-now = "bold bright green"
date-accessed-today = "magenta"
date-changed-flat = "dim"
date-created-old = "red"
"#,
);
let assertion = cmd.args(["--dump-theme=full-fat"]).assert().success();
let dump = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
for key in [
"date = \"white\"",
"date-now = \"bright cyan\"",
"date-modified = \"bright green\"",
"date-modified-now = \"bold bright green\"",
"date-accessed-today = \"magenta\"",
"date-changed-flat = \"dim\"",
"date-created-old = \"red\"",
] {
assert!(
dump.contains(key),
"--dump-theme output missing {key:?}:\n{dump}"
);
}
}
#[test]
fn dump_theme_groups_date_keys_by_column() {
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.grouped]
inherits = "exa"
directory = "bold blue"
size-major = "white"
date-created-now = "red"
date-now = "cyan"
date-modified-week = "yellow"
date-accessed-flat = "green"
date-today = "magenta"
date-changed-now = "orange"
"#,
);
let assertion = cmd.args(["--dump-theme=grouped"]).assert().success();
let dump = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
let body: Vec<&str> = dump
.lines()
.skip_while(|l| !l.starts_with("date") && !l.starts_with("directory"))
.collect();
let expected = vec![
"directory = \"bold blue\"",
"",
"size-major = \"white\"",
"",
"date-now = \"cyan\"",
"date-today = \"magenta\"",
"",
"date-modified-week = \"yellow\"",
"",
"date-accessed-flat = \"green\"",
"",
"date-changed-now = \"orange\"",
"",
"date-created-now = \"red\"",
];
assert_eq!(
body, expected,
"--dump-theme body does not match expected structured order:\n{dump}"
);
}
#[test]
fn per_column_gradient_tokens_reach_the_renderer() {
let work = tempdir().expect("failed to create workdir");
let file = work.path().join("fresh.txt");
fs::write(&file, "hi").unwrap();
let (_dir, mut cmd) = lx_with_theme(
r#"
version = "0.3"
[theme.rainbow]
inherits = "exa"
date-modified-now = "red"
date-accessed-now = "green"
date-changed-now = "blue"
date-created-now = "magenta"
"#,
);
let assertion = cmd
.args(["-lll", "--theme=rainbow"])
.arg(work.path())
.assert()
.success();
let out = String::from_utf8(assertion.get_output().stdout.clone()).unwrap();
for (colour, code) in [
("red", "\x1b[31m"),
("green", "\x1b[32m"),
("blue", "\x1b[34m"),
("magenta", "\x1b[35m"),
] {
assert!(
out.contains(code),
"per-column {colour} ({code:?}) did not reach the rendered output:\n{out}"
);
}
}
#[test]
fn new_gradient_tokens_are_accepted() {
let work = tempdir().expect("failed to create workdir");
fs::write(work.path().join("file.txt"), "hi").unwrap();
for tok in [
"modified",
"accessed",
"changed",
"created",
"size,modified",
"accessed,created",
] {
let mut cmd = assert_cmd::Command::cargo_bin("lx").expect("binary lx not found");
cmd.env("LX_CONFIG", "/nonexistent")
.env("HOME", "/nonexistent")
.env_remove("LS_COLORS")
.args(["-lll", "--colour=always", &format!("--gradient={tok}")])
.arg(work.path())
.assert()
.success();
}
}
#[test]
fn hidden_gradient_aliases_match_canonical() {
let work = tempdir().expect("failed to create workdir");
fs::write(work.path().join("file.txt"), "hi").unwrap();
let run = |value: &str| -> Vec<u8> {
let mut cmd = assert_cmd::Command::cargo_bin("lx").expect("binary lx not found");
cmd.env("LX_CONFIG", "/nonexistent")
.env("HOME", "/nonexistent")
.env_remove("LS_COLORS")
.args(["-l", "--colour=always", &format!("--gradient={value}")])
.arg(work.path())
.assert()
.success()
.get_output()
.stdout
.clone()
};
assert_eq!(
run("size"),
run("filesize"),
"--gradient=filesize should match --gradient=size"
);
assert_eq!(
run("date"),
run("timestamp"),
"--gradient=timestamp should match --gradient=date"
);
}
fn run_with_varied_mtimes(theme: &str, extra_args: &[&str]) -> Vec<u8> {
use std::time::{Duration, SystemTime};
let work = tempdir().expect("failed to create workdir");
let now = SystemTime::now();
let fixtures = [
("a_half_hour", Duration::from_secs(1800)), ("a_half_day", Duration::from_secs(43_200)), ("three_days", Duration::from_secs(3 * 86_400)), ("two_weeks", Duration::from_secs(14 * 86_400)), ("three_months", Duration::from_secs(90 * 86_400)), ];
for (name, age) in fixtures {
let path = work.path().join(name);
fs::File::create(&path).unwrap();
let t = std::fs::FileTimes::new().set_modified(now - age);
fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_times(t)
.unwrap();
}
let mut cmd = assert_cmd::Command::cargo_bin("lx").expect("binary lx not found");
cmd.env("LX_CONFIG", "/nonexistent")
.env("HOME", "/nonexistent")
.env_remove("LS_COLORS")
.args(["-l", "--colour=always", &format!("--theme={theme}")])
.args(extra_args)
.arg(work.path())
.assert()
.success()
.get_output()
.stdout
.clone()
}
#[test]
fn smooth_changes_24bit_output() {
let smooth = run_with_varied_mtimes("lx-24bit", &[]);
let discrete = run_with_varied_mtimes("lx-24bit", &["--no-smooth"]);
assert_ne!(
discrete, smooth,
"smoothing should change lx-24bit output relative to --no-smooth",
);
}
#[test]
fn smooth_is_noop_on_256_palette_theme() {
let smooth = run_with_varied_mtimes("lx-256", &[]);
let discrete = run_with_varied_mtimes("lx-256", &["--no-smooth"]);
assert_eq!(
discrete, smooth,
"smoothing should be a no-op on lx-256 (palette anchors gate it out)",
);
}
#[test]
fn smooth_is_noop_on_ansi_exa_theme() {
let smooth = run_with_varied_mtimes("exa", &[]);
let discrete = run_with_varied_mtimes("exa", &["--no-smooth"]);
assert_eq!(
discrete, smooth,
"smoothing should be a no-op on the exa theme (basic ANSI anchors)",
);
}
#[test]
fn smooth_with_no_gradient_is_harmless() {
let flat = run_with_varied_mtimes("lx-24bit", &["--no-gradient"]);
let flat_smooth = run_with_varied_mtimes("lx-24bit", &["--no-gradient", "--smooth"]);
assert_eq!(
flat, flat_smooth,
"--smooth --no-gradient should behave identically to --no-gradient alone",
);
}
#[test]
fn no_smooth_suppresses_personality_smooth() {
let dir = tempdir().expect("failed to create tempdir");
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
version = "0.5"
[personality.lx]
theme = "lx-24bit"
smooth = true
"#,
)
.unwrap();
let run = |extra: &[&str]| -> Vec<u8> {
use std::time::{Duration, SystemTime};
let work = tempdir().expect("failed to create workdir");
let path = work.path().join("f");
fs::File::create(&path).unwrap();
let t = std::fs::FileTimes::new()
.set_modified(SystemTime::now() - Duration::from_secs(3 * 86_400));
fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_times(t)
.unwrap();
let mut cmd = assert_cmd::Command::cargo_bin("lx").expect("binary lx not found");
cmd.env("LX_CONFIG", &config_path)
.env("HOME", "/nonexistent")
.env_remove("LS_COLORS")
.args(["-l", "--colour=always"])
.args(extra)
.arg(work.path())
.assert()
.success()
.get_output()
.stdout
.clone()
};
let with_personality = run(&[]); let forced_off = run(&["--no-smooth"]); assert_ne!(
with_personality, forced_off,
"--no-smooth should override a personality that enables smooth",
);
}