use std::io::Write;
use std::os::unix::ffi::OsStrExt;
use anstyle::{AnsiColor, Color, Effects, Style};
use crate::entry::Entry;
use crate::format::palette::Palette;
#[must_use]
pub fn format_name(palette: &Palette, entry: &Entry, dim_if_ignored: bool) -> Vec<u8> {
let base = palette.style_for(entry);
let dim = Style::new().effects(Effects::DIMMED);
let red = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red)));
let overlay = |s: Style| {
if dim_if_ignored {
s.effects(s.get_effects() | Effects::DIMMED)
} else {
s
}
};
let name_style = overlay(base);
let chain_bytes: usize = entry
.follow_chain
.iter()
.map(|p| p.as_os_str().len() + 16)
.sum();
let mut out = Vec::with_capacity(entry.name.len() + 32 + chain_bytes);
let segment = |out: &mut Vec<u8>, style: Style, bytes: &[u8]| {
let _ = write!(out, "{style}");
out.extend_from_slice(bytes);
let _ = write!(out, "{}", style.render_reset());
};
if let Some((final_target, hops)) = entry.follow_chain.split_last() {
let broken = entry.is_broken_link();
let link_style = if broken {
name_style
} else {
overlay(palette.style_for_symlink())
};
let final_style = if broken {
overlay(palette.style_for_missing_target().unwrap_or(red))
} else {
name_style
};
segment(&mut out, link_style, entry.name.as_bytes());
for intermediate in hops {
segment(&mut out, dim, " → ".as_bytes());
segment(&mut out, link_style, intermediate.as_os_str().as_bytes());
}
segment(&mut out, dim, " → ".as_bytes());
segment(&mut out, final_style, final_target.as_os_str().as_bytes());
return out;
}
segment(&mut out, name_style, entry.name.as_bytes());
out
}
#[cfg(test)]
mod tests {
use super::format_name;
use crate::entry::{Entry, EntryKind};
use crate::format::palette::Palette;
use std::ffi::OsString;
use std::path::PathBuf;
use std::time::SystemTime;
fn entry(name: &str, kind: EntryKind) -> Entry {
Entry {
name: OsString::from(name),
path: PathBuf::from(name),
kind,
mode: 0,
nlink: 0,
uid: 0,
gid: 0,
size: 0,
rdev: 0,
mtime: SystemTime::UNIX_EPOCH,
dev: 0,
ino: 0,
follow_chain: Vec::new(),
}
}
fn as_lossy(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
#[test]
fn formats_plain_file_name() {
let palette = Palette::empty();
let e = entry("hello", EntryKind::RegularFile);
let bytes = format_name(&palette, &e, false);
assert!(as_lossy(&bytes).contains("hello"));
}
#[test]
fn broken_symlink_renders_arrow_with_red_target_when_mi_unset() {
let palette = Palette::empty();
let mut e = entry("link", EntryKind::Symlink);
e.follow_chain = vec![PathBuf::from("nowhere")];
let s = as_lossy(&format_name(&palette, &e, false));
assert!(s.contains("link"));
assert!(s.contains('→'));
assert!(s.contains("nowhere"));
assert!(s.contains("31"), "expected red SGR for missing target: {s}");
}
#[test]
fn broken_symlink_target_uses_mi_when_palette_sets_it() {
let palette = Palette::from_string("mi=01;33");
let mut e = entry("link", EntryKind::Symlink);
e.follow_chain = vec![PathBuf::from("nowhere")];
let s = as_lossy(&format_name(&palette, &e, false));
assert!(s.contains("33"), "expected mi yellow on target: {s}");
assert!(!s.contains("31"), "freshl red should not appear: {s}");
}
#[test]
fn broken_multi_hop_chain_renders_full_path_with_red_tail() {
let palette = Palette::empty();
let mut e = entry("a", EntryKind::Symlink);
e.follow_chain = vec![PathBuf::from("mid"), PathBuf::from("gone")];
let s = as_lossy(&format_name(&palette, &e, false));
let a = s.find('a').expect("name missing");
let mid = s.find("mid").expect("intermediate missing");
let gone = s.find("gone").expect("tail missing");
assert!(
a < mid && mid < gone,
"expected forward order a → mid → gone: {s}"
);
assert_eq!(s.matches('→').count(), 2, "two arrows for two hops: {s}");
assert!(s.contains("31"), "unresolved tail should be red: {s}");
}
#[test]
fn ignored_files_get_dim_style() {
let palette = Palette::empty();
let e = entry("ignored", EntryKind::RegularFile);
let dim = format_name(&palette, &e, true);
let plain = format_name(&palette, &e, false);
assert_ne!(plain, dim);
}
#[test]
fn ignored_dim_preserves_palette_styling() {
let palette = Palette::from_string("di=01;34");
let e = entry("d", EntryKind::Directory);
let dim = as_lossy(&format_name(&palette, &e, true));
assert!(
dim.contains("34"),
"blue fg should survive dim overlay: {dim}"
);
assert!(dim.contains('2'), "dim effect should be present: {dim}");
}
#[test]
fn formats_follow_chain_with_arrows_forward_to_target() {
let palette = Palette::empty();
let mut e = entry("CLAUDE.md", EntryKind::RegularFile);
e.follow_chain = vec![PathBuf::from("AGENTS.md")];
let s = as_lossy(&format_name(&palette, &e, false));
let name_pos = s.find("CLAUDE.md").expect("name missing");
let target_pos = s.find("AGENTS.md").expect("target missing");
assert!(name_pos < target_pos, "name must precede target: {s}");
assert!(s.contains('→'), "forward arrow missing: {s}");
assert!(!s.contains("<-"), "no reverse arrow: {s}");
}
#[test]
fn formats_multi_hop_follow_chain_in_forward_order() {
let palette = Palette::empty();
let mut e = entry("top", EntryKind::RegularFile);
e.follow_chain = vec![PathBuf::from("mid"), PathBuf::from("target")];
let s = as_lossy(&format_name(&palette, &e, false));
let prefix_pos = s.find("top").expect("name missing");
let mid_pos = s.find("mid").expect("intermediate missing");
let target_pos = s.find("target").expect("target missing");
assert!(
prefix_pos < mid_pos && mid_pos < target_pos,
"expected forward order name → mid → target: {s}"
);
}
#[test]
fn follow_chain_left_side_uses_symlink_color() {
let palette = Palette::from_string("ln=01;36");
let mut e = entry("CLAUDE.md", EntryKind::RegularFile);
e.follow_chain = vec![PathBuf::from("AGENTS.md")];
let s = as_lossy(&format_name(&palette, &e, false));
assert!(s.contains("36"), "symlink cyan SGR missing: {s}");
}
#[test]
fn non_utf8_name_round_trips_exactly() {
use std::os::unix::ffi::OsStringExt;
let palette = Palette::empty();
let raw = vec![b'b', b'a', b'd', 0xFF, b'8'];
let e = Entry {
name: OsString::from_vec(raw.clone()),
path: PathBuf::from("bad"),
kind: EntryKind::RegularFile,
mode: 0,
nlink: 0,
uid: 0,
gid: 0,
size: 0,
rdev: 0,
mtime: SystemTime::UNIX_EPOCH,
dev: 0,
ino: 0,
follow_chain: Vec::new(),
};
let bytes = format_name(&palette, &e, false);
assert!(bytes.windows(raw.len()).any(|w| w == raw.as_slice()));
}
}