mod async_theme;
mod manage;
mod schema;
mod segments;
mod validate;
mod zsh;
use std::collections::HashSet;
use std::env;
use std::fmt::Write as _;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::runtime::Runtime;
pub use async_theme::AsyncTheme;
pub(crate) use manage::{apply, edit, list, persist};
use schema::{
AsyncLock, AsyncSection, CharacterTheme, Config, CustomSegmentTheme, CustomSegmentsConfig,
DirectoryTheme, GitSymbols, InputTheme, Layout, RuntimeTheme, Segments, Spacing, StatusTheme,
Style, SyntaxStyle, Theme,
};
pub(crate) use segments::ResolvedCustomSegment;
use segments::{resolve_custom_segments, validate_enabled_segments};
use validate::validate;
const DEFAULT_THEME_SELECTOR: &str = "catppuccin-mocha";
const CATPPUCCIN_MOCHA_THEME: &str = include_str!("../../themes/catppuccin-mocha.toml");
const VESPER_THEME: &str = include_str!("../../themes/vesper.toml");
const BUNDLED_THEMES: &[BundledTheme] = &[
BundledTheme {
selector: DEFAULT_THEME_SELECTOR,
overlay: None,
},
BundledTheme {
selector: "vesper",
overlay: Some(VESPER_THEME),
},
];
const THEME_VERSION: u64 = 1;
const MAX_LAYOUT_LINES: usize = 8;
const MAX_LAYOUT_SEGMENTS: usize = 64;
const MAX_SEGMENT_SPACING: u8 = 16;
const STYLE_RESET: &str = "%f%k%b%u%s";
struct BundledTheme {
selector: &'static str,
overlay: Option<&'static str>,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SegmentId {
Directory,
Clock,
Git,
Runtime(Runtime),
Character,
Status,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
enum LayoutSegment {
BuiltIn(SegmentId),
Custom(String),
}
#[derive(Clone)]
pub struct CompiledTheme {
theme: Theme,
layout: ValidatedLayout,
selector: String,
}
#[derive(Clone)]
struct ValidatedLayout {
lines: Vec<Vec<LayoutSegment>>,
right: Vec<LayoutSegment>,
asynchronous: Vec<SegmentId>,
}
impl LayoutSegment {
fn name(&self) -> &str {
match self {
Self::BuiltIn(segment) => segment.name(),
Self::Custom(name) => name,
}
}
const fn asynchronous(&self) -> Option<SegmentId> {
match self {
Self::BuiltIn(segment) if segment.is_async() => Some(*segment),
_ => None,
}
}
fn is_right_safe(&self) -> bool {
match self {
Self::Custom(_) => true,
Self::BuiltIn(segment) => segment.is_right_safe(),
}
}
const fn is_character(&self) -> bool {
matches!(self, Self::BuiltIn(SegmentId::Character))
}
}
impl ValidatedLayout {
fn custom_ids(&self) -> Vec<String> {
let mut seen = HashSet::new();
let mut ids = Vec::new();
for segment in self.lines.iter().flatten().chain(self.right.iter()) {
if let LayoutSegment::Custom(id) = segment
&& seen.insert(id)
{
ids.push(id.clone());
}
}
ids
}
fn sync_ids(&self) -> Vec<&str> {
let mut seen = HashSet::new();
let mut ids = Vec::new();
for segment in self.lines.iter().flatten().chain(self.right.iter()) {
let id = match segment {
LayoutSegment::BuiltIn(segment)
if matches!(
segment,
SegmentId::Directory
| SegmentId::Clock
| SegmentId::Character
| SegmentId::Status
) =>
{
segment.name()
}
LayoutSegment::Custom(id) => id.as_str(),
LayoutSegment::BuiltIn(_) => continue,
};
if seen.insert(id) {
ids.push(id);
}
}
ids
}
}
impl SegmentId {
pub fn parse(value: &str) -> Option<Self> {
match value {
"directory" => Some(Self::Directory),
"clock" => Some(Self::Clock),
"git" => Some(Self::Git),
"character" => Some(Self::Character),
"status" => Some(Self::Status),
value => Runtime::from_name(value).map(Self::Runtime),
}
}
pub const fn is_async(self) -> bool {
matches!(self, Self::Git | Self::Runtime(_))
}
pub const fn is_right_safe(self) -> bool {
matches!(self, Self::Directory | Self::Clock | Self::Status)
}
pub const fn name(self) -> &'static str {
match self {
Self::Directory => "directory",
Self::Clock => "clock",
Self::Git => "git",
Self::Runtime(runtime) => runtime.name(),
Self::Character => "character",
Self::Status => "status",
}
}
}
impl CompiledTheme {
pub fn load(override_selector: Option<&str>) -> io::Result<Self> {
let selector = match override_selector {
Some(selector) => Some(selector.to_owned()),
None => configured_theme()?,
}
.unwrap_or_else(|| DEFAULT_THEME_SELECTOR.to_owned());
let mut value = parse_versioned(CATPPUCCIN_MOCHA_THEME, "bundled Catppuccin Mocha theme")?;
if let Some(bundled) = bundled_theme(&selector) {
if let Some(source) = bundled.overlay {
let overlay =
parse_versioned(source, &format!("bundled {} theme", bundled.selector))?;
merge(&mut value, overlay);
}
} else {
let path = theme_path(&selector)?;
let source = fs::read_to_string(&path).map_err(|error| {
io::Error::new(
error.kind(),
format!("cannot read theme {}: {error}", path.display()),
)
})?;
let overlay = parse_versioned(&source, &format!("theme {}", path.display()))?;
merge(&mut value, overlay);
}
let theme: Theme = value
.try_into()
.map_err(|error| invalid(format!("invalid theme: {error}")))?;
if theme.version != THEME_VERSION {
return Err(invalid(format!(
"unsupported merged theme version {}",
theme.version
)));
}
let layout = validate(&theme)?;
Ok(Self {
theme,
layout,
selector,
})
}
pub(crate) fn git_enabled(&self) -> bool {
self.layout.asynchronous.contains(&SegmentId::Git)
}
pub(crate) fn custom_sources(&self) -> io::Result<Vec<ResolvedCustomSegment>> {
let active_ids = self.layout.custom_ids();
let config = load_config()?.unwrap_or_else(default_config);
validate_enabled_segments(&config.custom_segments.enabled)?;
if active_ids.is_empty() {
return Ok(Vec::new());
}
let root = config_root().ok_or_else(|| invalid("cannot determine config directory"))?;
resolve_custom_segments(&config, &active_ids, &root)
}
}
fn bundled_theme(selector: &str) -> Option<&'static BundledTheme> {
BUNDLED_THEMES
.iter()
.find(|theme| theme.selector == selector)
}
fn configured_theme() -> io::Result<Option<String>> {
Ok(load_config()?.map(|config| config.theme))
}
fn load_config() -> io::Result<Option<Config>> {
let Some(root) = config_root() else {
return Ok(None);
};
let path = root.join("ztheme/config.toml");
let source = match fs::read_to_string(&path) {
Ok(source) => source,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(io::Error::new(
error.kind(),
format!("cannot read {}: {error}", path.display()),
));
}
};
let config: Config = toml::from_str(&source)
.map_err(|error| invalid(format!("invalid {}: {error}", path.display())))?;
if config.version != THEME_VERSION {
return Err(invalid(format!(
"unsupported config version {} in {}",
config.version,
path.display()
)));
}
Ok(Some(config))
}
fn default_config() -> Config {
Config {
version: THEME_VERSION,
theme: DEFAULT_THEME_SELECTOR.to_owned(),
custom_segments: CustomSegmentsConfig::default(),
async_section: AsyncSection::default(),
}
}
pub(crate) fn async_lock() -> io::Result<AsyncLock> {
Ok(load_config()?
.map(|config| config.async_section.lock)
.unwrap_or_default())
}
fn config_root() -> Option<PathBuf> {
if let Some(root) = env::var_os("XDG_CONFIG_HOME") {
let root = PathBuf::from(root);
if root.is_absolute() {
return Some(root);
}
}
env::var_os("HOME")
.map(PathBuf::from)
.filter(|home| home.is_absolute())
.map(|home| home.join(".config"))
}
fn theme_path(selector: &str) -> io::Result<PathBuf> {
let path = Path::new(selector);
if path.is_absolute() {
return Ok(path.to_path_buf());
}
if selector.is_empty()
|| selector.len() > 128
|| !selector
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(invalid(
"theme must be a bundled theme, a simple name, or an absolute path",
));
}
let root = config_root().ok_or_else(|| invalid("cannot determine config directory"))?;
Ok(root.join("ztheme/themes").join(format!("{selector}.toml")))
}
fn parse_versioned(source: &str, name: &str) -> io::Result<toml::Value> {
let value: toml::Value =
toml::from_str(source).map_err(|error| invalid(format!("invalid {name}: {error}")))?;
let version = value
.get("version")
.and_then(toml::Value::as_integer)
.and_then(|value| u64::try_from(value).ok())
.ok_or_else(|| invalid(format!("{name} must contain integer `version = 1`")))?;
if version != THEME_VERSION {
return Err(invalid(format!(
"{name} uses unsupported version {version}"
)));
}
Ok(value)
}
fn merge(base: &mut toml::Value, overlay: toml::Value) {
match overlay {
toml::Value::Table(overlay_table) => {
if let Some(base_table) = base.as_table_mut() {
for (key, value) in overlay_table {
if let Some(base_value) = base_table.get_mut(&key) {
merge(base_value, value);
} else {
base_table.insert(key, value);
}
}
return;
}
*base = toml::Value::Table(overlay_table);
}
value => *base = value,
}
}
fn git_symbol_values(symbols: &GitSymbols) -> [(&'static str, &str); 9] {
[
("git.symbols.conflicted", &symbols.conflicted),
("git.symbols.staged", &symbols.staged),
("git.symbols.modified", &symbols.modified),
("git.symbols.deleted", &symbols.deleted),
("git.symbols.untracked", &symbols.untracked),
("git.symbols.ahead", &symbols.ahead),
("git.symbols.behind", &symbols.behind),
("git.symbols.diverged", &symbols.diverged),
("git.symbols.stash", &symbols.stash),
]
}
fn style_open(style: &Style, theme: &Theme) -> io::Result<String> {
let mut output = String::new();
if let Some(foreground) = style.foreground.as_deref() {
write!(output, "%F{{{}}}", resolve_color(foreground, theme)?)
.expect("writing to a String cannot fail");
}
if let Some(background) = style.background.as_deref() {
write!(output, "%K{{{}}}", resolve_color(background, theme)?)
.expect("writing to a String cannot fail");
}
if style.bold {
output.push_str("%B");
}
if style.underline {
output.push_str("%U");
}
if style.standout {
output.push_str("%S");
}
Ok(output)
}
fn highlight_style(style: &SyntaxStyle, theme: &Theme) -> io::Result<String> {
match style {
SyntaxStyle::Name(name) if name == "none" => Ok(name.clone()),
SyntaxStyle::Name(name) => Err(invalid(format!(
"syntax style name must be `none`, not `{name}`"
))),
SyntaxStyle::Style(style) => line_editor_style(style, theme),
}
}
fn line_editor_style(style: &Style, theme: &Theme) -> io::Result<String> {
let mut output = Vec::with_capacity(5);
if let Some(foreground) = style.foreground.as_deref() {
output.push(format!("fg={}", resolve_color(foreground, theme)?));
}
if let Some(background) = style.background.as_deref() {
output.push(format!("bg={}", resolve_color(background, theme)?));
}
if style.bold {
output.push("bold".to_owned());
}
if style.underline {
output.push("underline".to_owned());
}
if style.standout {
output.push("standout".to_owned());
}
if output.is_empty() {
return Ok("none".to_owned());
}
Ok(output.join(","))
}
fn valid_syntax_style_name(name: &str) -> bool {
const FIXED: &[&str] = &[
"alias",
"arg0",
"arithmetic-expansion",
"autodirectory",
"back-dollar-quoted-argument",
"back-double-quoted-argument",
"back-quoted-argument",
"back-quoted-argument-delimiter",
"back-quoted-argument-unclosed",
"bracket-error",
"builtin",
"command",
"command-substitution",
"command-substitution-delimiter",
"command-substitution-delimiter-quoted",
"command-substitution-delimiter-unquoted",
"command-substitution-quoted",
"command-substitution-unquoted",
"commandseparator",
"comment",
"cursor",
"cursor-matchingbracket",
"default",
"dollar-double-quoted-argument",
"dollar-quoted-argument",
"dollar-quoted-argument-unclosed",
"double-hyphen-option",
"double-quoted-argument",
"double-quoted-argument-unclosed",
"function",
"global-alias",
"globbing",
"hashed-command",
"history-expansion",
"line",
"named-fd",
"numeric-fd",
"path",
"path_pathseparator",
"path_prefix",
"path_prefix_pathseparator",
"precommand",
"process-substitution",
"process-substitution-delimiter",
"rc-quote",
"redirection",
"reserved-word",
"root",
"single-hyphen-option",
"single-quoted-argument",
"single-quoted-argument-unclosed",
"suffix-alias",
"unknown-token",
];
FIXED.binary_search(&name).is_ok()
|| name.strip_prefix("arg0_").is_some_and(valid_identifier)
|| name.strip_prefix("bracket-level-").is_some_and(|level| {
!level.is_empty() && level.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn resolve_color<'a>(value: &'a str, theme: &'a Theme) -> io::Result<&'a str> {
if valid_color(value) {
return Ok(value);
}
palette_color(theme, value)
}
fn palette_color<'a>(theme: &'a Theme, name: &str) -> io::Result<&'a str> {
theme
.palette
.get(name)
.map(String::as_str)
.ok_or_else(|| invalid(format!("unknown palette color `{name}`")))
}
fn valid_color(value: &str) -> bool {
value.len() == 7
&& value.starts_with('#')
&& value.as_bytes()[1..].iter().all(u8::is_ascii_hexdigit)
}
fn valid_identifier(value: &str) -> bool {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}
fn contains_control(value: &str) -> bool {
value.chars().any(char::is_control)
}
fn prompt_literal(value: &str) -> String {
value.replace('%', "%%")
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn invalid(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
#[cfg(test)]
pub(super) mod tests_support {
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
pub(super) fn write_theme_files<F: FnOnce(&Path)>(setup: F) -> (PathBuf, TempConfigDir) {
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"ztheme-segment-test-{}-{}",
std::process::id(),
SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(root.join("ztheme/segments")).unwrap();
setup(&root);
(root.clone(), TempConfigDir(root))
}
pub(super) struct TempConfigDir(PathBuf);
impl Drop for TempConfigDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
}
#[cfg(test)]
mod tests {
use super::{
CATPPUCCIN_MOCHA_THEME, CompiledTheme, DEFAULT_THEME_SELECTOR, Theme, merge,
parse_versioned, validate,
};
fn merged_theme(overlay: &str) -> std::io::Result<Theme> {
let mut value = parse_versioned(CATPPUCCIN_MOCHA_THEME, "Catppuccin Mocha")?;
merge(&mut value, parse_versioned(overlay, "overlay")?);
value
.try_into()
.map_err(|error| super::invalid(format!("invalid theme: {error}")))
}
fn validate_overlay(overlay: &str) -> std::io::Result<()> {
validate(&merged_theme(overlay)?).map(|_| ())
}
#[test]
fn bundled_catppuccin_mocha_theme_compiles_to_zsh() {
let theme = merged_theme("version = 1").unwrap();
let layout = validate(&theme).unwrap();
let compiled = CompiledTheme {
theme,
layout,
selector: DEFAULT_THEME_SELECTOR.to_owned(),
};
let zsh = compiled.zsh().unwrap();
assert!(zsh.contains("__ZTHEME_THEME_SELECTOR='catppuccin-mocha'"));
assert!(zsh.contains("_ztheme_render_layout()"));
assert!(zsh.contains("ZSH_HIGHLIGHT_STYLES[command]"));
assert!(!zsh.contains("@ZTHEME_"));
}
#[test]
fn compiled_theme_reports_whether_git_is_in_the_async_layout() {
let git_theme = merged_theme("version = 1\n[layout]\nlines = [[\"git\"]]").unwrap();
let git_layout = validate(&git_theme).unwrap();
assert!(
CompiledTheme {
theme: git_theme,
layout: git_layout,
selector: "git".to_owned(),
}
.git_enabled()
);
let runtime_theme = merged_theme("version = 1\n[layout]\nlines = [[\"python\"]]").unwrap();
let runtime_layout = validate(&runtime_theme).unwrap();
assert!(
!CompiledTheme {
theme: runtime_theme,
layout: runtime_layout,
selector: "runtime".to_owned(),
}
.git_enabled()
);
}
#[test]
fn compiled_theme_reports_async_group_presence() {
let git_theme = merged_theme("version = 1\n[layout]\nlines = [[\"git\"]]").unwrap();
let git_layout = validate(&git_theme).unwrap();
let zsh = CompiledTheme {
theme: git_theme,
layout: git_layout,
selector: "git".to_owned(),
}
.zsh()
.unwrap();
assert!(zsh.contains("__ZTHEME_HAS_GIT=1"));
assert!(zsh.contains("__ZTHEME_HAS_RUNTIME=0"));
let runtime_theme = merged_theme("version = 1\n[layout]\nlines = [[\"python\"]]").unwrap();
let runtime_layout = validate(&runtime_theme).unwrap();
let zsh = CompiledTheme {
theme: runtime_theme,
layout: runtime_layout,
selector: "runtime".to_owned(),
}
.zsh()
.unwrap();
assert!(zsh.contains("__ZTHEME_HAS_GIT=0"));
assert!(zsh.contains("__ZTHEME_HAS_RUNTIME=1"));
let sync_theme = merged_theme("version = 1\n[layout]\nlines = [[\"directory\"]]").unwrap();
let sync_layout = validate(&sync_theme).unwrap();
let zsh = CompiledTheme {
theme: sync_theme,
layout: sync_layout,
selector: "sync".to_owned(),
}
.zsh()
.unwrap();
assert!(zsh.contains("__ZTHEME_HAS_GIT=0"));
assert!(zsh.contains("__ZTHEME_HAS_RUNTIME=0"));
assert!(zsh.contains("__ZTHEME_HAS_ASYNC=0"));
}
fn async_group_clear(generated: &str) -> &str {
let start = generated.find("_ztheme_clear_async_group() {").unwrap();
let rest = &generated[start..];
let end = rest.find("\n}\n").unwrap();
&rest[..end]
}
fn branch_body(helper: &str, label: &str) -> Option<String> {
let needle = format!("\n {label})\n");
let start = helper.find(&needle).map(|index| index + needle.len())?;
let rest = &helper[start..];
let end = rest.find(";;\n")?;
Some(rest[..end].to_owned())
}
#[test]
fn generated_group_clear_is_scoped_to_the_errored_group() {
let theme =
merged_theme("version = 1\n[layout]\nlines = [[\"git\", \"python\", \"rust\"]]")
.unwrap();
let layout = validate(&theme).unwrap();
let zsh = CompiledTheme {
theme,
layout,
selector: "groups".to_owned(),
}
.zsh()
.unwrap();
let helper = async_group_clear(&zsh);
let git = branch_body(helper, "git").unwrap();
assert!(git.contains("ZTHEME_SEGMENT_GIT=''"));
assert!(!git.contains("PYTHON"));
assert!(!git.contains("RUST"));
let runtime = branch_body(helper, "runtime").unwrap();
assert!(runtime.contains("ZTHEME_SEGMENT_PYTHON=''"));
assert!(runtime.contains("ZTHEME_SEGMENT_RUST=''"));
assert!(!runtime.contains("GIT"));
let theme = merged_theme("version = 1\n[layout]\nlines = [[\"python\"]]").unwrap();
let layout = validate(&theme).unwrap();
let zsh = CompiledTheme {
theme,
layout,
selector: "runtime-only".to_owned(),
}
.zsh()
.unwrap();
let helper = async_group_clear(&zsh);
assert!(branch_body(helper, "git").is_none());
let runtime = branch_body(helper, "runtime").unwrap();
assert!(runtime.contains("ZTHEME_SEGMENT_PYTHON=''"));
assert!(!runtime.contains("GIT"));
}
#[test]
fn async_lock_defaults_and_parses_overrides() {
use crate::theme::schema::Config;
use crate::theme::schema::{AsyncLock, AsyncSection};
let config: Config = toml::from_str("version = 1\ntheme = \"x\"\n").unwrap();
assert!(config.async_section.lock.git_segment);
assert!(!config.async_section.lock.runtime_segment);
let config: Config =
toml::from_str("version = 1\ntheme = \"x\"\n[async.lock]\ngit_segment = false\n")
.unwrap();
assert!(!config.async_section.lock.git_segment);
assert!(!config.async_section.lock.runtime_segment);
let config: Config = toml::from_str(
"version = 1\ntheme = \"x\"\n[async.lock]\ngit_segment = false\nruntime_segment = false\n",
)
.unwrap();
assert!(!config.async_section.lock.git_segment);
assert!(!config.async_section.lock.runtime_segment);
let config: Config =
toml::from_str("version = 1\ntheme = \"x\"\n[async.lock]\nruntime_segment = true\n")
.unwrap();
assert!(config.async_section.lock.git_segment);
assert!(config.async_section.lock.runtime_segment);
assert!(AsyncSection::default().lock.git_segment);
assert!(!AsyncSection::default().lock.runtime_segment);
assert!(AsyncLock::default().git_segment);
assert!(!AsyncLock::default().runtime_segment);
assert!(AsyncSection::default().is_default());
}
#[test]
fn overlays_replace_only_explicit_values() {
let theme = merged_theme(
r##"
version = 1
[palette]
path = "#112233"
[layout]
lines = [["directory"], ["character"]]
"##,
)
.unwrap();
assert_eq!(theme.palette["path"], "#112233");
assert_eq!(theme.palette["accent"], "#f9e2af");
assert_eq!(theme.layout.lines.len(), 2);
assert_eq!(theme.layout.lines[0], ["directory"]);
assert_eq!(theme.segments.git.symbol, " ");
validate(&theme).unwrap();
}
#[test]
fn invalid_theme_contracts_are_rejected() {
let cases = [
("unknown field", "version = 1\nsurprise = true"),
(
"duplicate segment",
"version = 1\n[layout]\nlines = [[\"directory\", \"directory\"]]",
),
(
"async right segment",
"version = 1\n[layout]\nright = [\"git\"]",
),
(
"misplaced character",
"version = 1\n[layout]\nlines = [[\"character\", \"directory\"]]",
),
(
"invalid palette color",
"version = 1\n[palette]\naccent = \"orange\"",
),
(
"control character",
"version = 1\n[segments.git]\nsymbol = \"bad\\n\"",
),
(
"invalid width",
"version = 1\n[segments.directory.width]\npercent = 0",
),
(
"invalid spacing",
"version = 1\n[segments.character.spacing]\nafter = 17",
),
(
"unknown syntax style",
"version = 1\n[input.syntax]\nnot-a-real-style = \"none\"",
),
];
for (name, overlay) in cases {
assert!(
validate_overlay(overlay).is_err(),
"accepted invalid case: {name}"
);
}
}
#[test]
fn unsupported_or_missing_versions_are_rejected_before_merge() {
assert!(parse_versioned("theme = \"x\"", "missing").is_err());
assert!(parse_versioned("version = 2", "future").is_err());
assert!(parse_versioned("version = \"1\"", "wrong type").is_err());
}
#[test]
fn configured_custom_segments_work_on_left_and_right() {
let overlay = r#"
version = 1
[layout]
lines = [["directory", "time"]]
right = ["cpu", "status"]
[segments.custom.time]
prefix = "☀ "
style = { foreground = "accent" }
[segments.custom.cpu]
style = { foreground = "muted" }
"#;
let layout = validate(&merged_theme(overlay).unwrap()).unwrap();
assert_eq!(layout.lines[0][1].name(), "time");
assert_eq!(layout.right[0].name(), "cpu");
assert!(layout.asynchronous.is_empty());
}
#[test]
fn layout_custom_segment_without_configuration_fails() {
let overlay = r#"
version = 1
[layout]
lines = [["directory", "time"]]
"#;
let error = validate_overlay(overlay).unwrap_err();
assert!(error.to_string().contains("missing configuration"));
}
#[test]
fn reserved_custom_segment_ids_are_rejected() {
for reserved in ["git", "python", "directory", "clock", "status", "character"] {
let overlay = format!(
"version = 1\n[layout]\nlines = [[\"directory\"]]\n\
[segments.custom.{reserved}]\nstyle = {{ foreground = \"accent\" }}\n"
);
assert!(
validate_overlay(&overlay).is_err(),
"accepted reserved id `{reserved}`"
);
}
}
#[test]
fn invalid_custom_segment_ids_and_layout_duplicates_are_rejected() {
let overlay = r#"
version = 1
[layout]
lines = [["directory", "time-time"]]
[segments.custom.time-time]
style = { foreground = "accent" }
"#;
assert!(validate_overlay(overlay).is_err());
let overlay = r#"
version = 1
[layout]
lines = [["directory", "time"], ["time"]]
[segments.custom.time]
style = { foreground = "accent" }
"#;
let error = validate_overlay(overlay).unwrap_err();
assert!(error.to_string().contains("more than once"));
}
#[test]
fn unused_custom_style_entries_are_allowed() {
let overlay = r#"
version = 1
[layout]
lines = [["directory"]]
[segments.custom.time]
style = { foreground = "accent" }
"#;
assert!(validate_overlay(overlay).is_ok());
}
#[test]
fn custom_segment_theme_contracts_are_validated() {
for overlay in [
"version = 1\n[layout]\nlines = [[\"time\"]]\n[segments.custom.time]\nprefix = \"bad\\n\"\nstyle = { foreground = \"accent\" }\n",
"version = 1\n[layout]\nlines = [[\"time\"]]\n[segments.custom.time]\nstyle = { foreground = \"nope\" }\n",
"version = 1\n[layout]\nlines = [[\"time\"]]\n[segments.custom.time]\nstyle = { foreground = \"accent\" }\nspacing = { after = 17 }\n",
] {
assert!(validate_overlay(overlay).is_err(), "accepted {overlay}");
}
}
#[test]
fn character_position_restriction_still_holds_with_custom_segments() {
let overlay = r#"
version = 1
[layout]
lines = [["character", "time"]]
[segments.custom.time]
style = { foreground = "accent" }
"#;
assert!(validate_overlay(overlay).is_err());
}
}