use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::style::Rect;
use crate::ui_snapshot::{UiSnapshotFileFormat, UiSnapshotOptions};
const SNAPSHOT_ENV: &str = "TUI_LIPAN_SNAPSHOT";
const VIEWPORT_ENV: &str = "TUI_LIPAN_SNAPSHOT_VIEWPORT";
const VIEWPORTS_ENV: &str = "TUI_LIPAN_SNAPSHOT_VIEWPORTS";
const FRAMES_ENV: &str = "TUI_LIPAN_SNAPSHOT_FRAMES";
const FOCUS_ENV: &str = "TUI_LIPAN_SNAPSHOT_FOCUS";
const KEYS_ENV: &str = "TUI_LIPAN_SNAPSHOT_KEYS";
const SCRIPT_ENV: &str = "TUI_LIPAN_SNAPSHOT_SCRIPT";
const DIAGNOSTIC_ENV: &str = "TUI_LIPAN_SNAPSHOT_DIAGNOSTIC";
const ADVANCE_ENV: &str = "TUI_LIPAN_SNAPSHOT_ADVANCE_MS";
const SETTLE_ENV: &str = "TUI_LIPAN_SNAPSHOT_SETTLE_MS";
const DEFAULT_VIEWPORT: Rect = Rect {
x: 0,
y: 0,
w: 100,
h: 30,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct HeadlessSnapshotConfig {
pub(super) path: PathBuf,
pub(super) format: UiSnapshotFileFormat,
pub(super) viewports: Vec<Rect>,
pub(super) suffix_viewports: bool,
pub(super) frames: usize,
pub(super) focus_steps: usize,
pub(super) actions: Vec<crate::ui_snapshot::Action>,
pub(super) diagnostic: bool,
pub(super) advance: Duration,
pub(super) settle: Duration,
}
impl HeadlessSnapshotConfig {
pub(super) fn from_env() -> Option<crate::Result<Self>> {
let raw = std::env::var_os(SNAPSHOT_ENV)?;
if raw.is_empty() {
return None;
}
let path = PathBuf::from(raw);
let format = UiSnapshotFileFormat::from_path(&path);
let actions = match crate::ui_snapshot::resolve_actions(
std::env::var(SCRIPT_ENV).ok().as_deref(),
std::env::var(KEYS_ENV).ok().as_deref(),
) {
Ok(actions) => actions,
Err(err) => return Some(Err(err)),
};
let (viewports, suffix_viewports) = match env_viewports() {
Ok(resolved) => resolved,
Err(err) => return Some(Err(err)),
};
Some(Ok(Self {
path,
format,
viewports,
suffix_viewports,
frames: env_usize(FRAMES_ENV).unwrap_or(1).max(1),
focus_steps: env_usize(FOCUS_ENV).unwrap_or(0),
actions,
diagnostic: std::env::var(DIAGNOSTIC_ENV).as_deref() == Ok("1"),
advance: env_duration_ms(ADVANCE_ENV).unwrap_or(Duration::ZERO),
settle: env_duration_ms(SETTLE_ENV).unwrap_or(Duration::ZERO),
}))
}
pub(super) fn primary_viewport(&self) -> Rect {
self.viewports.first().copied().unwrap_or(DEFAULT_VIEWPORT)
}
pub(super) fn output_path(&self, viewport: Rect) -> PathBuf {
snapshot_output_path(&self.path, viewport, self.suffix_viewports)
}
pub(super) fn snapshot_options(&self) -> UiSnapshotOptions {
if self.diagnostic {
UiSnapshotOptions::diagnostic()
} else {
UiSnapshotOptions::default()
}
}
pub(super) fn missing_format_feature(&self) -> Option<&'static str> {
if self.format != UiSnapshotFileFormat::Markdown {
return None;
}
let extension = self.path.extension()?;
if extension.eq_ignore_ascii_case("png") {
return Some("ui-snapshot-png");
}
if extension.eq_ignore_ascii_case("json") {
return Some("ui-snapshot-json");
}
None
}
}
fn env_viewports() -> crate::Result<(Vec<Rect>, bool)> {
if let Some(raw) = std::env::var(VIEWPORTS_ENV)
.ok()
.filter(|s| !s.trim().is_empty())
{
return Ok((parse_viewports(&raw)?, true));
}
Ok((vec![env_viewport().unwrap_or(DEFAULT_VIEWPORT)], false))
}
fn env_viewport() -> Option<Rect> {
parse_viewport_spec(&std::env::var(VIEWPORT_ENV).ok()?)
}
fn parse_viewports(raw: &str) -> crate::Result<Vec<Rect>> {
let mut viewports = Vec::new();
for spec in raw.split(',') {
let spec = spec.trim();
if spec.is_empty() {
return Err(std::io::Error::other(format!(
"invalid {VIEWPORTS_ENV} entry: empty size \
(check for a trailing comma); expected WIDTHxHEIGHT"
))
.into());
}
let Some(rect) = parse_viewport_spec(spec) else {
return Err(std::io::Error::other(format!(
"invalid {VIEWPORTS_ENV} entry `{spec}`: \
expected WIDTHxHEIGHT with non-zero width and height"
))
.into());
};
viewports.push(rect);
}
Ok(viewports)
}
fn parse_viewport_spec(raw: &str) -> Option<Rect> {
let (w, h) = raw.split_once(['x', 'X'])?;
let w: u16 = w.trim().parse().ok()?;
let h: u16 = h.trim().parse().ok()?;
if w == 0 || h == 0 {
return None;
}
Some(Rect { x: 0, y: 0, w, h })
}
fn snapshot_output_path(path: &Path, viewport: Rect, suffix: bool) -> PathBuf {
if !suffix {
return path.to_path_buf();
}
let stem = path
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_else(|| "snapshot".to_owned());
let name = match path.extension() {
Some(ext) => format!(
"{stem}-{}x{}.{}",
viewport.w,
viewport.h,
ext.to_string_lossy()
),
None => format!("{stem}-{}x{}", viewport.w, viewport.h),
};
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.join(name),
_ => PathBuf::from(name),
}
}
fn env_usize(key: &str) -> Option<usize> {
std::env::var(key).ok()?.trim().parse().ok()
}
fn env_duration_ms(key: &str) -> Option<Duration> {
let ms: u64 = std::env::var(key).ok()?.trim().parse().ok()?;
Some(Duration::from_millis(ms))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markdown_is_the_fallback_for_unknown_extensions() {
assert_eq!(
UiSnapshotFileFormat::from_path(std::path::Path::new("out.txt")),
UiSnapshotFileFormat::Markdown
);
assert_eq!(
UiSnapshotFileFormat::from_path(std::path::Path::new("out")),
UiSnapshotFileFormat::Markdown
);
}
#[cfg(feature = "ui-snapshot-png")]
#[test]
fn png_extension_routes_to_png_case_insensitively() {
assert_eq!(
UiSnapshotFileFormat::from_path(std::path::Path::new("shot.PNG")),
UiSnapshotFileFormat::Png
);
}
#[test]
fn diagnostic_flag_selects_diagnostic_options() {
let config = HeadlessSnapshotConfig {
path: PathBuf::from("out.md"),
format: UiSnapshotFileFormat::Markdown,
viewports: vec![DEFAULT_VIEWPORT],
suffix_viewports: false,
frames: 1,
focus_steps: 0,
actions: Vec::new(),
diagnostic: true,
advance: Duration::ZERO,
settle: Duration::ZERO,
};
assert_eq!(config.snapshot_options(), UiSnapshotOptions::diagnostic());
}
#[test]
fn parse_viewports_accepts_a_comma_separated_list() {
assert_eq!(
parse_viewports("80x24,120x30,160x40").expect("valid list"),
vec![
Rect {
x: 0,
y: 0,
w: 80,
h: 24
},
Rect {
x: 0,
y: 0,
w: 120,
h: 30
},
Rect {
x: 0,
y: 0,
w: 160,
h: 40
},
]
);
assert_eq!(
parse_viewports("100X20").expect("case-insensitive x"),
vec![Rect {
x: 0,
y: 0,
w: 100,
h: 20
}]
);
}
#[test]
fn parse_viewports_rejects_malformed_and_zero_sizes() {
let err = parse_viewports("80x24, nope, 120x30").expect_err("typo");
let message = err.to_string();
assert!(
message.contains("nope") && message.contains(VIEWPORTS_ENV),
"{message}"
);
let zero = parse_viewports("80x24,0x10").expect_err("zero width");
assert!(zero.to_string().contains("0x10"), "{zero}");
let empty = parse_viewports("80x24,").expect_err("trailing comma");
assert!(empty.to_string().contains(VIEWPORTS_ENV), "{empty}");
}
#[test]
fn snapshot_output_path_suffixes_only_when_requested() {
let path = PathBuf::from("/tmp/app.png");
let viewport = Rect {
x: 0,
y: 0,
w: 80,
h: 24,
};
assert_eq!(
snapshot_output_path(&path, viewport, false),
PathBuf::from("/tmp/app.png")
);
assert_eq!(
snapshot_output_path(&path, viewport, true),
PathBuf::from("/tmp/app-80x24.png")
);
assert_eq!(
snapshot_output_path(Path::new("out"), viewport, true),
PathBuf::from("out-80x24")
);
}
}