use std::collections::HashSet;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use super::{Config, SegmentId, invalid};
const MAX_CUSTOM_ID_BYTES: usize = 64;
const CUSTOM_SEGMENT_HEADER_PREFIX: &str = "# ztheme-segment-v1: ";
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ResolvedCustomSegment {
pub(crate) id: String,
pub(crate) path: PathBuf,
}
pub(super) fn valid_custom_identifier(value: &str) -> bool {
let bytes = value.as_bytes();
(1..=MAX_CUSTOM_ID_BYTES).contains(&bytes.len())
&& bytes[0].is_ascii_lowercase()
&& bytes
.iter()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_')
&& SegmentId::parse(value).is_none()
}
pub(super) fn validate_enabled_segments(enabled: &[String]) -> io::Result<()> {
let mut seen = HashSet::new();
for id in enabled {
if !valid_custom_identifier(id) {
return Err(invalid(format!(
"invalid custom segment id `{id}` in [custom_segments] enabled"
)));
}
if !seen.insert(id) {
return Err(invalid(format!(
"custom segment `{id}` appears more than once in [custom_segments] enabled"
)));
}
}
Ok(())
}
pub(super) fn resolve_custom_segments(
config: &Config,
active_ids: &[String],
config_root: &Path,
) -> io::Result<Vec<ResolvedCustomSegment>> {
let segments_dir = config_root.join("ztheme/segments");
let mut resolved = Vec::with_capacity(active_ids.len());
for id in active_ids {
if !config
.custom_segments
.enabled
.iter()
.any(|enabled| enabled == id)
{
return Err(invalid(format!(
"custom segment `{id}` is not enabled; add it to [custom_segments] in {}",
config_root.join("ztheme/config.toml").display()
)));
}
let path = segments_dir.join(format!("{id}.zsh"));
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Err(invalid(format!(
"custom segment file {} does not exist",
path.display()
)));
}
Err(error) => return Err(error),
};
if !metadata.file_type().is_file() {
return Err(invalid(format!(
"custom segment file {} is not a regular file",
path.display()
)));
}
let first_line = first_line(&path)?;
let expected = format!("{CUSTOM_SEGMENT_HEADER_PREFIX}{id}");
if first_line != expected {
return Err(invalid(format!(
"custom segment file {} must start with `{expected}`",
path.display()
)));
}
resolved.push(ResolvedCustomSegment {
id: id.clone(),
path,
});
}
Ok(resolved)
}
fn first_line(path: &Path) -> io::Result<String> {
let bytes = fs::read(path)?;
let end = bytes
.iter()
.position(|byte| *byte == b'\n')
.unwrap_or(bytes.len());
Ok(String::from_utf8_lossy(&bytes[..end])
.trim_end_matches('\r')
.to_owned())
}
#[cfg(test)]
mod tests {
use super::{resolve_custom_segments, valid_custom_identifier, validate_enabled_segments};
use crate::theme::tests_support::write_theme_files;
use crate::theme::{Config, CustomSegmentsConfig};
fn config(enabled: &[&str]) -> Config {
Config {
version: 1,
theme: "test".to_owned(),
custom_segments: CustomSegmentsConfig {
enabled: enabled.iter().map(|value| (*value).to_owned()).collect(),
},
}
}
fn segments_dir(root: &std::path::Path) -> std::path::PathBuf {
root.join("ztheme/segments")
}
#[test]
fn identifier_grammar_accepts_and_rejects() {
for valid in ["time", "cpu_load", "a", "z9_0"] {
assert!(valid_custom_identifier(valid), "rejected `{valid}`");
}
for invalid in [
"",
"Clock",
"0time",
"time-time",
"time.time",
"time time",
"C",
"a".repeat(65).as_str(),
"git",
"character",
"status",
"directory",
"clock",
"python",
"rust",
"node",
] {
assert!(!valid_custom_identifier(invalid), "accepted `{invalid}`");
}
}
#[test]
fn allowlist_rejects_duplicates_and_invalid_ids() {
assert!(validate_enabled_segments(&[]).is_ok());
assert!(validate_enabled_segments(&["time".to_owned()]).is_ok());
assert!(validate_enabled_segments(&["time".to_owned(), "cpu".to_owned()]).is_ok());
assert!(
validate_enabled_segments(&["time".to_owned(), "time".to_owned()]).is_err(),
"accepted a duplicate"
);
assert!(
validate_enabled_segments(&["time-time".to_owned()]).is_err(),
"accepted an invalid id"
);
assert!(
validate_enabled_segments(&["git".to_owned()]).is_err(),
"accepted a reserved id"
);
assert!(
validate_enabled_segments(&["python".to_owned()]).is_err(),
"accepted a runtime id"
);
}
#[test]
fn enabled_active_segment_resolves_with_matching_file_and_header() {
let (root, _guard) = write_theme_files(|home| {
std::fs::write(
segments_dir(home).join("time.zsh"),
"# ztheme-segment-v1: time\nztheme_segment_time() { :; }\n",
)
.unwrap();
});
let resolved =
resolve_custom_segments(&config(&["time"]), &["time".to_owned()], &root).unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].id, "time");
assert_eq!(resolved[0].path, segments_dir(&root).join("time.zsh"));
}
#[test]
fn unlisted_random_files_are_ignored() {
let (root, _guard) = write_theme_files(|home| {
std::fs::write(segments_dir(home).join("random.zsh"), "print random\n").unwrap();
});
assert!(resolve_custom_segments(&config(&["time"]), &["time".to_owned()], &root).is_err());
let resolved = resolve_custom_segments(&config(&[]), &[], &root).unwrap();
assert!(resolved.is_empty());
}
#[test]
fn active_but_disabled_segment_fails() {
let (root, _guard) = write_theme_files(|home| {
std::fs::write(
segments_dir(home).join("time.zsh"),
"# ztheme-segment-v1: time\n",
)
.unwrap();
});
let error = resolve_custom_segments(&config(&[]), &["time".to_owned()], &root).unwrap_err();
assert!(error.to_string().contains("not enabled"));
}
#[test]
fn missing_active_file_fails() {
let (root, _guard) = write_theme_files(|_| {});
let error =
resolve_custom_segments(&config(&["time"]), &["time".to_owned()], &root).unwrap_err();
assert!(error.to_string().contains("does not exist"));
}
#[test]
fn headerless_file_fails() {
let (root, _guard) = write_theme_files(|home| {
std::fs::write(
segments_dir(home).join("time.zsh"),
"ztheme_segment_time() { :; }\n",
)
.unwrap();
});
assert!(resolve_custom_segments(&config(&["time"]), &["time".to_owned()], &root).is_err());
}
#[test]
fn mismatched_or_unsupported_header_fails() {
for header in [
"# ztheme-segment-v1: watch\n",
"# ztheme-segment-v2: time\n",
"# ztheme-segment-v1:cloc\n",
" # ztheme-segment-v1: time\n",
] {
let (root, _guard) = write_theme_files(|home| {
std::fs::write(segments_dir(home).join("time.zsh"), header).unwrap();
});
let error = resolve_custom_segments(&config(&["time"]), &["time".to_owned()], &root)
.unwrap_err();
assert!(
error.to_string().contains("must start with"),
"accepted header {header:?}: {error}"
);
}
}
#[test]
fn symlink_and_non_regular_files_fail() {
let (root, _guard) = write_theme_files(|home| {
std::fs::write(
segments_dir(home).join("target.zsh"),
"# ztheme-segment-v1: time\n",
)
.unwrap();
std::os::unix::fs::symlink(
segments_dir(home).join("target.zsh"),
segments_dir(home).join("time.zsh"),
)
.unwrap();
std::fs::create_dir_all(segments_dir(home).join("cpu.zsh")).unwrap();
});
assert!(
resolve_custom_segments(&config(&["time"]), &["time".to_owned()], &root).is_err(),
"accepted a symlink"
);
assert!(
resolve_custom_segments(&config(&["cpu"]), &["cpu".to_owned()], &root).is_err(),
"accepted a directory"
);
}
#[test]
fn enabled_but_inactive_segment_is_not_sourced() {
let (root, _guard) = write_theme_files(|home| {
std::fs::write(
segments_dir(home).join("time.zsh"),
"# ztheme-segment-v1: time\n",
)
.unwrap();
std::fs::write(
segments_dir(home).join("cpu.zsh"),
"# ztheme-segment-v1: cpu\n",
)
.unwrap();
});
let resolved =
resolve_custom_segments(&config(&["time", "cpu"]), &["time".to_owned()], &root)
.unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].id, "time");
}
#[test]
fn crlf_line_endings_are_tolerated() {
let (root, _guard) = write_theme_files(|home| {
std::fs::write(
segments_dir(home).join("time.zsh"),
"# ztheme-segment-v1: time\r\nztheme_segment_time() { :; }\r\n",
)
.unwrap();
});
assert!(resolve_custom_segments(&config(&["time"]), &["time".to_owned()], &root).is_ok());
}
}