use crate::config::{CustomColors, PromptConfig, Theme};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptStyle {
Classic,
Fish,
Powerline,
Pure,
Custom,
}
impl PromptStyle {
pub fn from_str(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"fish" => Self::Fish,
"powerline" | "omp" | "oh-my-posh" => Self::Powerline,
"pure" | "minimal" => Self::Pure,
"custom" => Self::Custom,
_ => Self::Classic,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GitInfo {
pub branch: String,
pub dirty: bool,
pub detached: bool,
}
#[derive(Debug, Clone)]
pub struct PromptContext {
pub cwd: String,
pub home: String,
pub user: String,
pub host: String,
pub git: Option<GitInfo>,
pub status: i32,
pub duration_ms: u64,
pub time: String,
pub columns: u16,
pub colors: bool,
}
impl Default for PromptContext {
fn default() -> Self {
Self {
cwd: String::new(),
home: String::new(),
user: String::new(),
host: String::new(),
git: None,
status: 0,
duration_ms: 0,
time: String::new(),
columns: 80,
colors: true,
}
}
}
impl PromptContext {
pub fn current(cfg: &PromptConfig) -> Self {
let cwd = std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default();
let home = dirs::home_dir()
.map(|p| p.display().to_string())
.unwrap_or_default();
let git = if cfg.show_git {
discover_git(Path::new(&cwd), cfg.show_git_dirty)
} else {
None
};
Self {
git,
user: current_user(),
host: short_hostname(),
time: chrono::Local::now().format("%H:%M").to_string(),
columns: terminal_columns(),
colors: crate::config::get_config().colors.enabled,
cwd,
home,
..Default::default()
}
}
pub fn for_test(cwd: &str, user: &str, host: &str) -> Self {
Self {
cwd: cwd.to_string(),
home: "/home/ada".to_string(),
user: user.to_string(),
host: host.to_string(),
time: "12:00".to_string(),
..Default::default()
}
}
pub fn with_result(mut self, status: i32, duration_ms: u64) -> Self {
self.status = status;
self.duration_ms = duration_ms;
self
}
}
fn fg(hex: &str, on: bool) -> String {
ansi_color(hex, 38, on)
}
fn bg(hex: &str, on: bool) -> String {
ansi_color(hex, 48, on)
}
fn ansi_color(hex: &str, base: u8, on: bool) -> String {
if !on {
return String::new();
}
match parse_hex(hex) {
Some((r, g, b)) => format!("\x1b[{base};2;{r};{g};{b}m"),
None => String::new(),
}
}
fn reset(on: bool) -> &'static str {
if on {
"\x1b[0m"
} else {
""
}
}
fn bold(on: bool) -> &'static str {
if on {
"\x1b[1m"
} else {
""
}
}
pub fn parse_hex(hex: &str) -> Option<(u8, u8, u8)> {
let h = hex.trim().trim_start_matches('#');
if h.len() != 6 {
return None;
}
Some((
u8::from_str_radix(&h[0..2], 16).ok()?,
u8::from_str_radix(&h[2..4], 16).ok()?,
u8::from_str_radix(&h[4..6], 16).ok()?,
))
}
fn palette() -> CustomColors {
let config = crate::config::get_config();
if config.colors.theme == "custom" {
config.colors.custom.clone()
} else {
Theme::from_str(&config.colors.theme).colors()
}
}
pub fn abbreviate_path(cwd: &str, home: &str, abbreviate: bool, max_segments: usize) -> String {
let normalized = cwd.replace('\\', "/");
let home_n = home.replace('\\', "/");
let (prefix, rest) = if !home_n.is_empty()
&& (normalized == home_n
|| normalized.starts_with(&format!("{}/", home_n.trim_end_matches('/'))))
{
(
"~",
normalized[home_n.trim_end_matches('/').len()..].to_string(),
)
} else {
("", normalized.clone())
};
let mut parts: Vec<String> = rest
.split('/')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let mut elided = false;
if max_segments > 0 && parts.len() > max_segments {
parts = parts.split_off(parts.len() - max_segments);
elided = true;
}
if abbreviate && parts.len() > 1 {
let last = parts.len() - 1;
for (i, part) in parts.iter_mut().enumerate() {
if i == last {
continue;
}
*part = shorten_component(part);
}
}
let joined = parts.join("/");
match (prefix.is_empty(), elided) {
(true, true) => format!("…/{joined}"),
(true, false)
if joined.starts_with(|c: char| c.is_ascii_alphabetic())
&& joined[1..].starts_with(':') =>
{
joined
}
(true, false) => format!("/{joined}"),
(false, true) => format!("~/…/{joined}"),
(false, false) => {
if joined.is_empty() {
prefix.to_string()
} else {
format!("{prefix}/{joined}")
}
}
}
}
fn shorten_component(part: &str) -> String {
if part.ends_with(':') {
return part.to_string();
}
let mut chars = part.chars();
match chars.next() {
Some('.') => match chars.next() {
Some(c) => format!(".{c}"),
None => ".".to_string(),
},
Some(c) => c.to_string(),
None => String::new(),
}
}
pub fn discover_git(start: &Path, want_dirty: bool) -> Option<GitInfo> {
let mut dir = Some(start);
while let Some(d) = dir {
let dot_git = d.join(".git");
if dot_git.exists() {
let head_path = if dot_git.is_dir() {
dot_git.join("HEAD")
} else {
let contents = std::fs::read_to_string(&dot_git).ok()?;
let gitdir = contents.trim().strip_prefix("gitdir:")?.trim();
Path::new(gitdir).join("HEAD")
};
let head = std::fs::read_to_string(head_path).ok()?;
let info = parse_head(head.trim());
return Some(GitInfo {
dirty: want_dirty && worktree_dirty(d),
..info
});
}
dir = d.parent();
}
None
}
pub fn parse_head(head: &str) -> GitInfo {
if let Some(rest) = head.strip_prefix("ref:") {
let branch = rest
.trim()
.rsplit('/')
.next()
.unwrap_or(rest.trim())
.to_string();
GitInfo {
branch,
dirty: false,
detached: false,
}
} else {
GitInfo {
branch: head.chars().take(7).collect(),
dirty: false,
detached: true,
}
}
}
fn worktree_dirty(repo: &Path) -> bool {
std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["status", "--porcelain", "--untracked-files=no"])
.output()
.map(|o| !o.stdout.is_empty())
.unwrap_or(false)
}
fn current_user() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "user".to_string())
}
fn short_hostname() -> String {
let raw = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| "localhost".to_string());
raw.split('.').next().unwrap_or(&raw).to_string()
}
fn terminal_columns() -> u16 {
#[cfg(feature = "native")]
{
crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80)
}
#[cfg(not(feature = "native"))]
{
80
}
}
pub fn format_duration(ms: u64) -> String {
if ms < 1000 {
return format!("{ms}ms");
}
let total_secs = ms / 1000;
if total_secs < 60 {
return format!("{}.{}s", total_secs, (ms % 1000) / 100);
}
let mins = total_secs / 60;
let secs = total_secs % 60;
if mins < 60 {
return format!("{mins}m{secs:02}s");
}
format!("{}h{:02}m", mins / 60, mins % 60)
}
pub fn visible_width(s: &str) -> usize {
let mut width = 0usize;
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
let mut peek = chars.clone();
if peek.next() == Some('[') {
chars.next();
}
for c2 in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&c2) {
break;
}
}
continue;
}
if c == '\n' {
width = 0;
continue;
}
width += 1;
}
width
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Segment {
pub name: String,
pub text: String,
pub fg: String,
pub bg: String,
}
pub fn build_segment(name: &str, cfg: &PromptConfig, ctx: &PromptContext) -> Option<Segment> {
let p = palette();
const DARK_TEXT: &str = "#1e1e2e"; const LIGHT_TEXT: &str = "#cdd6f4"; const CHROME_BG: &str = "#45475a";
const CHROME_BG_ALT: &str = "#585b70";
let (text, fg_color, bg_color) = match name {
"os" => (
os_glyph().to_string(),
LIGHT_TEXT.to_string(),
CHROME_BG.to_string(),
),
"user" => (
ctx.user.clone(),
LIGHT_TEXT.to_string(),
CHROME_BG_ALT.to_string(),
),
"host" => (
ctx.host.clone(),
LIGHT_TEXT.to_string(),
CHROME_BG_ALT.to_string(),
),
"user@host" => (
format!("{}@{}", ctx.user, ctx.host),
LIGHT_TEXT.to_string(),
CHROME_BG_ALT.to_string(),
),
"cwd" | "path" => (
abbreviate_path(
&ctx.cwd,
&ctx.home,
cfg.abbreviate_path,
cfg.max_path_segments,
),
DARK_TEXT.to_string(),
p.punctuation.clone(),
),
"git" | "git_branch" => {
let g = ctx.git.as_ref()?;
let glyph = if g.detached { "➦" } else { GLYPH_BRANCH };
let dirty = if g.dirty { " ●" } else { "" };
(
format!("{glyph} {}{dirty}", g.branch),
DARK_TEXT.to_string(),
if g.dirty {
p.warning.clone()
} else {
p.success.clone()
},
)
}
"status" => {
if ctx.status == 0 {
return None;
}
(
format!("✘ {}", ctx.status),
DARK_TEXT.to_string(),
p.error.clone(),
)
}
"duration" | "time_taken" => {
if !cfg.show_time || ctx.duration_ms < cfg.time_threshold_ms {
return None;
}
(
format!(" {}", format_duration(ctx.duration_ms)),
DARK_TEXT.to_string(),
p.warning.clone(),
)
}
"time" | "clock" => (
ctx.time.clone(),
LIGHT_TEXT.to_string(),
CHROME_BG.to_string(),
),
"symbol" => (
cfg.symbol.clone(),
DARK_TEXT.to_string(),
if ctx.status == 0 {
p.success.clone()
} else {
p.error.clone()
},
),
other => (
other.to_string(),
LIGHT_TEXT.to_string(),
CHROME_BG.to_string(),
),
};
if text.trim().is_empty() {
return None;
}
let (fg_color, bg_color) = match cfg.segment_colors.get(name) {
Some(spec) => parse_color_spec(spec, &fg_color, &bg_color),
None => (fg_color, bg_color),
};
Some(Segment {
name: name.to_string(),
text,
fg: fg_color,
bg: bg_color,
})
}
fn parse_color_spec(spec: &str, default_fg: &str, default_bg: &str) -> (String, String) {
let mut parts = spec.splitn(2, ':');
let f = parts.next().unwrap_or("").trim();
let b = parts.next().unwrap_or("").trim();
(
if f.is_empty() {
default_fg.to_string()
} else {
f.to_string()
},
if b.is_empty() {
default_bg.to_string()
} else {
b.to_string()
},
)
}
const GLYPH_WINDOWS: &str = "\u{e70f}"; const GLYPH_APPLE: &str = "\u{f179}"; const GLYPH_LINUX: &str = "\u{f17c}"; const GLYPH_BRANCH: &str = "\u{e0a0}";
fn os_glyph() -> &'static str {
if cfg!(target_os = "windows") {
GLYPH_WINDOWS
} else if cfg!(target_os = "macos") {
GLYPH_APPLE
} else {
GLYPH_LINUX
}
}
pub fn render_left(cfg: &PromptConfig, ctx: &PromptContext) -> String {
match PromptStyle::from_str(&cfg.style) {
PromptStyle::Classic => render_classic(cfg, ctx),
PromptStyle::Fish => render_fish(cfg, ctx),
PromptStyle::Powerline => render_powerline(cfg, ctx),
PromptStyle::Pure => render_pure(cfg, ctx),
PromptStyle::Custom => expand_format(&cfg.format, cfg, ctx),
}
}
pub fn render_right(cfg: &PromptConfig, ctx: &PromptContext) -> String {
if cfg.right.is_empty() {
return String::new();
}
expand_format(&cfg.right, cfg, ctx)
}
pub fn render_line(cfg: &PromptConfig, ctx: &PromptContext) -> String {
let left = render_left(cfg, ctx);
let right = render_right(cfg, ctx);
if right.is_empty() {
return left;
}
let (lw, rw) = (visible_width(&left), visible_width(&right));
let cols = ctx.columns as usize;
if lw + rw + 1 >= cols {
return left;
}
format!("{left}{}{right}", " ".repeat(cols - lw - rw))
}
fn render_classic(cfg: &PromptConfig, ctx: &PromptContext) -> String {
let p = palette();
let on = ctx.colors;
let status_color = if ctx.status == 0 { &p.key } else { &p.error };
format!(
"{}æ{}{}{} ",
fg(status_color, on),
fg(&p.dim, on),
cfg.symbol,
reset(on)
)
}
fn render_fish(cfg: &PromptConfig, ctx: &PromptContext) -> String {
let p = palette();
let on = ctx.colors;
let mut out = String::new();
if cfg.show_user_host {
out.push_str(&format!(
"{}{}@{}{} ",
fg(&p.string, on),
ctx.user,
ctx.host,
reset(on)
));
}
out.push_str(&format!(
"{}{}{}{}",
bold(on),
fg(&p.keyword, on),
abbreviate_path(
&ctx.cwd,
&ctx.home,
cfg.abbreviate_path,
cfg.max_path_segments
),
reset(on)
));
if let Some(g) = ctx.git.as_ref() {
let color = if g.dirty { &p.warning } else { &p.success };
let dirty = if g.dirty { "●" } else { "" };
out.push_str(&format!(
" {}on {GLYPH_BRANCH} {}{}{}",
fg(color, on),
g.branch,
dirty,
reset(on)
));
}
if cfg.show_time && ctx.duration_ms >= cfg.time_threshold_ms {
out.push_str(&format!(
" {}{}{}",
fg(&p.dim, on),
format_duration(ctx.duration_ms),
reset(on)
));
}
if ctx.status != 0 {
out.push_str(&format!(
" {}[{}]{}",
fg(&p.error, on),
ctx.status,
reset(on)
));
}
let arrow_color = if ctx.status == 0 {
&p.success
} else {
&p.error
};
out.push_str(&format!(
" {}{}{} ",
fg(arrow_color, on),
cfg.symbol,
reset(on)
));
out
}
fn render_powerline(cfg: &PromptConfig, ctx: &PromptContext) -> String {
let on = ctx.colors;
let segments: Vec<Segment> = cfg
.segments
.iter()
.filter_map(|name| build_segment(name, cfg, ctx))
.collect();
if segments.is_empty() {
return render_classic(cfg, ctx);
}
let sep = &cfg.powerline_separator;
let mut out = String::new();
for (i, seg) in segments.iter().enumerate() {
out.push_str(&bg(&seg.bg, on));
out.push_str(&fg(&seg.fg, on));
out.push_str(&format!(" {} ", seg.text.trim()));
match segments.get(i + 1) {
Some(next) => {
out.push_str(reset(on));
out.push_str(&bg(&next.bg, on));
out.push_str(&fg(&seg.bg, on));
out.push_str(sep);
}
None => {
out.push_str(reset(on));
out.push_str(&fg(&seg.bg, on));
out.push_str(sep);
out.push_str(reset(on));
}
}
}
if cfg.two_line {
out.push('\n');
let p = palette();
let arrow_color = if ctx.status == 0 {
&p.success
} else {
&p.error
};
out.push_str(&format!(
"{}{}{} ",
fg(arrow_color, on),
cfg.symbol,
reset(on)
));
} else {
out.push(' ');
}
out
}
fn render_pure(cfg: &PromptConfig, ctx: &PromptContext) -> String {
let p = palette();
let on = ctx.colors;
let mut out = format!(
"{}{}{}",
fg(&p.keyword, on),
abbreviate_path(&ctx.cwd, &ctx.home, false, cfg.max_path_segments),
reset(on)
);
if let Some(g) = ctx.git.as_ref() {
out.push_str(&format!(
" {}{}{}{}",
fg(&p.dim, on),
g.branch,
if g.dirty { "*" } else { "" },
reset(on)
));
}
let arrow_color = if ctx.status == 0 {
&p.keyword
} else {
&p.error
};
out.push_str(&format!(
"\n{}{}{} ",
fg(arrow_color, on),
cfg.symbol,
reset(on)
));
out
}
pub fn expand_format(format: &str, cfg: &PromptConfig, ctx: &PromptContext) -> String {
let p = palette();
let on = ctx.colors;
let git = ctx
.git
.as_ref()
.map(|g| {
format!(
"{}{}{}{}",
fg(&p.success, on),
g.branch,
if g.dirty { "*" } else { "" },
reset(on)
)
})
.unwrap_or_default();
let status = if ctx.status == 0 {
String::new()
} else {
format!("{}{}{}", fg(&p.error, on), ctx.status, reset(on))
};
let symbol_color = if ctx.status == 0 { &p.key } else { &p.error };
let mut out = format.to_string();
for (key, value) in [
(
"{cwd}",
abbreviate_path(
&ctx.cwd,
&ctx.home,
cfg.abbreviate_path,
cfg.max_path_segments,
),
),
("{full_cwd}", ctx.cwd.clone()),
("{user}", ctx.user.clone()),
("{host}", ctx.host.clone()),
("{git_branch}", git),
("{time}", ctx.time.clone()),
("{status}", status),
("{duration}", format_duration(ctx.duration_ms)),
(
"{symbol}",
format!("{}{}{}", fg(symbol_color, on), cfg.symbol, reset(on)),
),
("{newline}", "\n".to_string()),
] {
if out.contains(key) {
out = out.replace(key, &value);
}
}
out
}
pub fn render_transient(cfg: &PromptConfig, ctx: &PromptContext) -> String {
let p = palette();
let on = ctx.colors;
format!("{}{}{} ", fg(&p.dim, on), cfg.symbol, reset(on))
}
pub fn render_continuation(cfg: &PromptConfig, ctx: &PromptContext) -> String {
let p = palette();
format!(
"{}{}{}",
fg(&p.dim, ctx.colors),
cfg.continuation,
reset(ctx.colors)
)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(style: &str) -> PromptConfig {
PromptConfig {
style: style.to_string(),
..Default::default()
}
}
fn ctx() -> PromptContext {
PromptContext {
colors: false,
..PromptContext::for_test("/home/ada/dev/nervosys/cli/AetherShell", "ada", "box")
}
}
#[test]
fn abbreviates_like_fish() {
assert_eq!(
abbreviate_path(
"/home/ada/dev/nervosys/cli/AetherShell",
"/home/ada",
true,
0
),
"~/d/n/c/AetherShell"
);
}
#[test]
fn keeps_full_path_when_abbreviation_disabled() {
assert_eq!(
abbreviate_path("/home/ada/dev/proj", "/home/ada", false, 0),
"~/dev/proj"
);
}
#[test]
fn home_itself_is_just_tilde() {
assert_eq!(abbreviate_path("/home/ada", "/home/ada", true, 0), "~");
}
#[test]
fn sibling_of_home_is_not_collapsed() {
let out = abbreviate_path("/home/ada2/work", "/home/ada", true, 0);
assert!(!out.starts_with('~'), "got {out}");
}
#[test]
fn hidden_directories_keep_their_dot() {
assert_eq!(
abbreviate_path("/home/ada/.config/aether/themes", "/home/ada", true, 0),
"~/.c/a/themes"
);
}
#[test]
fn max_segments_elides_the_middle() {
let out = abbreviate_path("/home/ada/a/b/c/d/e", "/home/ada", true, 2);
assert_eq!(out, "~/…/d/e");
}
#[test]
fn windows_separators_are_normalized() {
let out = abbreviate_path(r"C:\Users\ada\dev\proj", "", true, 0);
assert_eq!(out, "C:/U/a/d/proj");
}
#[test]
fn parses_branch_from_head() {
let info = parse_head("ref: refs/heads/feature/prompt-styles");
assert_eq!(info.branch, "prompt-styles");
assert!(!info.detached);
}
#[test]
fn parses_detached_head_as_short_sha() {
let info = parse_head("6a5c11ad9f3e2b1c0d");
assert_eq!(info.branch, "6a5c11a");
assert!(info.detached);
}
#[test]
fn formats_durations_by_magnitude() {
assert_eq!(format_duration(250), "250ms");
assert_eq!(format_duration(1_250), "1.2s");
assert_eq!(format_duration(194_000), "3m14s");
assert_eq!(format_duration(7_320_000), "2h02m");
}
#[test]
fn visible_width_ignores_ansi() {
let colored = format!("{}abc{}", fg("#ff0000", true), reset(true));
assert_eq!(visible_width(&colored), 3);
}
#[test]
fn fish_prompt_shows_abbreviated_path_and_branch() {
let mut c = ctx();
c.git = Some(GitInfo {
branch: "master".into(),
dirty: false,
detached: false,
});
let out = render_fish(&cfg("fish"), &c);
assert!(out.contains("~/d/n/c/AetherShell"), "got {out}");
assert!(out.contains("master"), "got {out}");
}
#[test]
fn fish_prompt_reports_failure_status() {
let c = ctx().with_result(127, 0);
let out = render_fish(&cfg("fish"), &c);
assert!(out.contains("[127]"), "got {out}");
}
#[test]
fn duration_is_hidden_below_the_threshold() {
let c = ctx().with_result(0, 10);
let out = render_fish(&cfg("fish"), &c);
assert!(!out.contains("10ms"), "got {out}");
}
#[test]
fn powerline_joins_segments_with_separators() {
let mut c = ctx();
c.git = Some(GitInfo {
branch: "master".into(),
dirty: true,
detached: false,
});
let mut config = cfg("powerline");
config.segments = vec!["cwd".into(), "git".into()];
let out = render_powerline(&config, &c);
assert!(out.contains("~/d/n/c/AetherShell"), "got {out}");
assert!(out.contains("master"), "got {out}");
assert!(out.contains(&config.powerline_separator), "got {out}");
}
#[test]
fn powerline_omits_empty_segments() {
let mut config = cfg("powerline");
config.segments = vec!["cwd".into(), "git".into(), "status".into()];
let segs: Vec<_> = config
.segments
.iter()
.filter_map(|n| build_segment(n, &config, &ctx()))
.collect();
assert_eq!(segs.len(), 1);
assert_eq!(segs[0].name, "cwd");
}
#[test]
fn powerline_falls_back_when_all_segments_are_empty() {
let mut config = cfg("powerline");
config.segments = vec!["git".into(), "status".into()];
let out = render_powerline(&config, &ctx());
assert!(out.contains('æ'), "expected classic fallback, got {out}");
}
#[test]
fn custom_style_expands_documented_placeholders() {
let mut config = cfg("custom");
config.format = "{user}@{host} {cwd} {symbol}".into();
let out = render_left(&config, &ctx());
assert!(out.contains("ada@box"), "got {out}");
assert!(out.contains("~/d/n/c/AetherShell"), "got {out}");
assert!(out.contains('❯'), "got {out}");
}
#[test]
fn right_prompt_is_padded_to_the_terminal_edge() {
let mut config = cfg("classic");
config.right = "{time}".into();
let mut c = ctx();
c.columns = 40;
let line = render_line(&config, &c);
assert_eq!(visible_width(&line), 40);
}
#[test]
fn right_prompt_is_dropped_when_it_would_not_fit() {
let mut config = cfg("classic");
config.right = "{time}".into();
let mut c = ctx();
c.columns = 4;
let line = render_line(&config, &c);
assert!(!line.contains("12:00"), "got {line}");
}
#[test]
fn unknown_style_degrades_to_classic() {
assert_eq!(PromptStyle::from_str("nonsense"), PromptStyle::Classic);
let out = render_left(&cfg("nonsense"), &ctx());
assert!(out.contains('æ'), "got {out}");
}
#[test]
fn colors_disabled_emits_no_escapes() {
let mut c = ctx();
c.colors = false;
for style in ["classic", "fish", "powerline", "pure"] {
let out = render_left(&cfg(style), &c);
assert!(!out.contains('\x1b'), "{style} emitted escapes: {out:?}");
}
}
#[test]
fn glyphs_are_non_empty() {
assert!(!os_glyph().is_empty());
assert!(!GLYPH_BRANCH.is_empty());
for g in [GLYPH_WINDOWS, GLYPH_APPLE, GLYPH_LINUX, GLYPH_BRANCH] {
assert_eq!(g.chars().count(), 1, "expected exactly one codepoint");
}
}
#[test]
fn os_segment_renders() {
let seg = build_segment("os", &cfg("powerline"), &ctx());
assert!(seg.is_some(), "the os segment must not be dropped as empty");
}
#[test]
fn every_segment_has_contrasting_foreground_and_background() {
let mut c = ctx();
c.git = Some(GitInfo {
branch: "master".into(),
dirty: false,
detached: false,
});
let dirty_ctx = PromptContext {
git: Some(GitInfo {
branch: "master".into(),
dirty: true,
detached: false,
}),
..c.clone()
}
.with_result(1, 5_000);
let config = cfg("powerline");
for name in [
"os",
"user",
"host",
"user@host",
"cwd",
"git",
"status",
"duration",
"time",
"symbol",
"»",
] {
for context in [&c, &dirty_ctx] {
if let Some(seg) = build_segment(name, &config, context) {
assert_ne!(
seg.fg, seg.bg,
"segment '{name}' is invisible: fg == bg == {}",
seg.fg
);
assert!(
parse_hex(&seg.fg).is_some(),
"segment '{name}' has an unparseable fg {:?}",
seg.fg
);
assert!(
parse_hex(&seg.bg).is_some(),
"segment '{name}' has an unparseable bg {:?}",
seg.bg
);
}
}
}
}
#[test]
fn segment_color_override_wins_over_palette() {
let mut config = cfg("powerline");
config
.segment_colors
.insert("cwd".into(), "#123456:#654321".into());
let seg = build_segment("cwd", &config, &ctx()).unwrap();
assert_eq!(seg.fg, "#123456");
assert_eq!(seg.bg, "#654321");
}
#[test]
fn segment_color_override_accepts_foreground_only() {
let mut config = cfg("powerline");
config.segment_colors.insert("cwd".into(), "#123456".into());
let seg = build_segment("cwd", &config, &ctx()).unwrap();
assert_eq!(seg.fg, "#123456");
assert!(!seg.bg.is_empty(), "background should keep its default");
}
}