use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use clap::Args;
use excalidraw_render::{
ColorMode, CompatibilityDiagnostic, FrameBatchErrorKind, FrameRenderRequest, MetadataOptions,
OutputFormat, PdfOptions, PdfRasterFallback, Renderer, ResourceLimits, SceneDocument, Sizing,
SupplementalAssets,
};
use regex::Regex;
use serde::{Serialize, Serializer};
use unicode_normalization::UnicodeNormalization as _;
use crate::human::{self, HumanReport};
use crate::{
deliver_report_bytes, destination_exists, destination_is_taken, emergency_report_failure,
emit_failure, excalidraw_release, json_line, not_a_directory, parse_asset_declarations,
path_conflict, path_entry_exists, read_bounded_asset, read_scene, report_output, same_path,
sha256, stage_report_file, validate_paths, validate_report_options, write_stdout, write_stream,
ArtifactDescriptor, Cli, CliDetails, CliError, CliOperation, DeliveryError, DeliveryRole,
DeliveryState, Failure, FormatArg, PdfFallbackArg, ReportCommand, ReportError, ReportFormat,
StagedFile, ASSETS, EXIT_OPERATIONAL, EXIT_USAGE, METADATA, OUTPUT, RENDERING, REPORTING,
};
pub(crate) const LONG_ABOUT: &str = "\
Render each selected frame of an Excalidraw scene to its own file in a
directory.
Every frame, including AI-generated (magic) frames, is exported with zero
padding and named after the frame (or frame-N for unnamed frames). By default
nothing is written unless every frame succeeds; use --list to preview the
selection and file names.";
pub(crate) const EXAMPLES: &str = "\
Examples:
excalidraw-render frames drawing.excalidraw --list
excalidraw-render frames drawing.excalidraw -o frames --format png
excalidraw-render frames drawing.excalidraw -o slides --format pdf --name-regex '^Slide'";
const SELECTION: &str = "Frame selection";
#[derive(Args)]
pub(crate) struct FramesCli {
input: String,
#[arg(
short,
long,
value_name = "DIRECTORY",
allow_hyphen_values = true,
required_unless_present = "list",
help_heading = OUTPUT
)]
output: Option<String>,
#[arg(
short,
long,
value_enum,
required_unless_present = "list",
help_heading = OUTPUT
)]
format: Option<FormatArg>,
#[arg(long, help_heading = OUTPUT)]
force: bool,
#[arg(long, help_heading = OUTPUT)]
allow_partial: bool,
#[arg(long, value_name = "REGEX", help_heading = SELECTION)]
name_regex: Option<String>,
#[arg(long, value_name = "N", default_value_t = 256, help_heading = SELECTION)]
max_frames: u32,
#[arg(long, help_heading = SELECTION)]
list: bool,
#[arg(
long,
value_name = "FACTOR",
allow_negative_numbers = true,
conflicts_with = "max_dimension",
help_heading = RENDERING
)]
scale: Option<f64>,
#[arg(
long,
value_name = "PX",
allow_negative_numbers = true,
help_heading = RENDERING
)]
max_dimension: Option<f64>,
#[arg(long, help_heading = RENDERING)]
no_background: bool,
#[arg(long, help_heading = RENDERING)]
dark_mode: bool,
#[arg(long, value_enum, value_name = "MODE", help_heading = RENDERING)]
pdf_raster_fallback: Option<PdfFallbackArg>,
#[arg(long, help_heading = METADATA)]
embed_scene: bool,
#[arg(long, help_heading = METADATA)]
embed_provenance: bool,
#[arg(long, help_heading = METADATA)]
embed_diagnostics: bool,
#[arg(long, value_name = "FILE_ID=PATH", help_heading = ASSETS)]
asset: Vec<String>,
#[arg(long, value_name = "FILE_ID=MEDIA_TYPE", help_heading = ASSETS)]
asset_mime: Vec<String>,
#[arg(long, value_enum, default_value_t, help_heading = REPORTING)]
report_format: ReportFormat,
#[arg(long, value_name = "PATH", help_heading = REPORTING)]
report_output: Option<String>,
#[arg(short, long, help_heading = REPORTING)]
verbose: bool,
}
impl FramesCli {
fn output(&self) -> &str {
self.output
.as_deref()
.expect("--output is required outside list mode")
}
fn format(&self) -> FormatArg {
self.format.expect("--format is required outside list mode")
}
fn report_cli(&self) -> Cli {
Cli {
input: Some(self.input.clone()),
output: self.output.clone(),
format: self.format,
no_background: self.no_background,
padding: None,
scale: self.scale,
max_dimension: self.max_dimension,
dark_mode: self.dark_mode,
embed_scene: self.embed_scene,
embed_provenance: self.embed_provenance,
embed_diagnostics: self.embed_diagnostics,
no_frame_rendering: false,
no_frame_clipping: false,
no_frame_names: false,
no_frame_outlines: false,
asset: self.asset.clone(),
asset_mime: self.asset_mime.clone(),
pdf_raster_fallback: self.pdf_raster_fallback,
report_format: self.report_format,
report_output: self.report_output.clone(),
force: self.force,
verbose: self.verbose,
version: false,
licenses: false,
command: if self.list {
ReportCommand::FramesList
} else {
ReportCommand::FramesExport
},
}
}
}
#[derive(Serialize)]
struct FrameCliEntry {
ordinal: u32,
kind: &'static str,
name: Option<String>,
output_path: String,
artifact: Option<ArtifactDescriptor>,
error: Option<ReportError>,
diagnostics: Vec<CompatibilityDiagnostic>,
delivery_state: DeliveryState,
}
struct PreparedFrameBatch {
entries: Vec<(FrameCliEntry, Option<StagedFile>)>,
global_diagnostics: Vec<CompatibilityDiagnostic>,
had_failure: bool,
}
#[derive(Serialize)]
struct FrameListEntry {
ordinal: u32,
kind: &'static str,
name: Option<String>,
#[serde(rename = "output_basename")]
basename: String,
}
#[derive(Serialize)]
struct FrameListReport<'a> {
schema_version: u32,
command: ReportCommand,
status: &'static str,
renderer_version: &'static str,
excalidraw_release: &'static str,
frames: &'a [FrameListEntry],
}
#[derive(Serialize)]
struct FrameBatchReport<'a> {
schema_version: u32,
command: ReportCommand,
status: &'a str,
renderer_version: &'static str,
excalidraw_release: &'static str,
format: OutputFormat,
delivery_state: DeliveryState,
diagnostics: &'a [CompatibilityDiagnostic],
#[serde(serialize_with = "serialize_frame_entries")]
frames: &'a [(FrameCliEntry, Option<StagedFile>)],
}
fn serialize_frame_entries<S: Serializer>(
entries: &&[(FrameCliEntry, Option<StagedFile>)],
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.collect_seq(entries.iter().map(|(entry, _)| entry))
}
pub(crate) fn run_frames(frames: FramesCli) -> u8 {
if frames.list {
return run_frames_list(&frames);
}
let cli = frames.report_cli();
let format = OutputFormat::from(frames.format());
let result = prepare_and_render_frames(&frames);
match result {
Ok(batch) => deliver_frame_batch(
&cli,
format,
batch.entries,
batch.global_diagnostics,
batch.had_failure,
frames.allow_partial,
),
Err(failure) => emit_failure(&cli, Some(format), failure),
}
}
fn run_frames_list(frames: &FramesCli) -> u8 {
let cli = frames.report_cli();
match prepare_frame_list(frames) {
Ok(entries) => deliver_frame_list(&cli, &entries, frames.name_regex.as_deref()),
Err(failure) => emit_failure(&cli, None, failure),
}
}
fn prepare_frame_list(frames: &FramesCli) -> Result<Vec<FrameListEntry>, Failure> {
if frames.report_format == ReportFormat::None {
return Err(Failure::cli(
"cli.invalid-arguments",
"--list does not accept --report-format none",
None,
EXIT_USAGE,
));
}
let FramePreamble { regex, .. } = prepare_frame_preamble(frames)?;
let report_path = frames.report_output.as_deref().unwrap_or("stderr");
if report_path != "stderr" {
validate_paths(&frames.input, "-", report_path, &BTreeMap::new())?;
if destination_is_taken(Path::new(report_path)) && !frames.force {
return Err(destination_exists(DeliveryRole::Report, report_path));
}
}
let scene = read_scene(&frames.input)?;
let selected = select_frames(&scene, regex.as_ref());
if selected.len() > frames.max_frames as usize {
return Err(frame_limit_exceeded(frames));
}
let basenames = frame_output_basenames(&selected, frames.format.map(OutputFormat::from));
Ok(selected
.iter()
.zip(basenames)
.map(|(frame, basename)| FrameListEntry {
ordinal: frame.ordinal(),
kind: frame.kind().as_str(),
name: frame.name().map(str::to_owned),
basename,
})
.collect())
}
fn deliver_frame_list(cli: &Cli, entries: &[FrameListEntry], name_regex: Option<&str>) -> u8 {
let bytes = match cli.report_format {
ReportFormat::Human if entries.is_empty() => {
let note = match name_regex {
Some(regex) => format!("note: no frames match --name-regex '{regex}'\n"),
None => "note: the scene has no frames\n".to_owned(),
};
return deliver_report_bytes(cli, note.as_bytes(), DeliveryState::None);
}
ReportFormat::Human => entries
.iter()
.map(|entry| {
format!(
"{}\t{}\t{}\t{}\n",
entry.ordinal,
entry.kind,
entry.name.as_deref().unwrap_or(""),
entry.basename
)
})
.collect::<String>()
.into_bytes(),
ReportFormat::Json => frame_list_json_line(entries),
ReportFormat::None => Vec::new(),
};
if report_output(cli) == "stderr" {
write_stdout(&bytes)
} else {
deliver_report_bytes(cli, &bytes, DeliveryState::None)
}
}
fn frame_list_json_line(entries: &[FrameListEntry]) -> Vec<u8> {
json_line(&FrameListReport {
schema_version: 1,
command: ReportCommand::FramesList,
status: "success",
renderer_version: env!("CARGO_PKG_VERSION"),
excalidraw_release: excalidraw_release(),
frames: entries,
})
}
fn validate_frame_limit(frames: &FramesCli) -> Result<(), Failure> {
if frames.max_frames == 0 || frames.max_frames > 16_384 {
return Err(Failure::cli(
"cli.invalid-arguments",
"--max-frames must be between 1 and 16384",
Some(CliDetails::CliArgument {
argument: "--max-frames".to_owned(),
value: Some(frames.max_frames.to_string()),
}),
EXIT_USAGE,
));
}
Ok(())
}
fn frame_name_regex(frames: &FramesCli) -> Result<Option<Regex>, Failure> {
frames
.name_regex
.as_deref()
.map(Regex::new)
.transpose()
.map_err(|error| {
let error = error.to_string();
let reason = error
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.map_or(error.as_str(), |line| {
line.trim().strip_prefix("error: ").unwrap_or(line.trim())
});
Failure::cli(
"cli.invalid-name-regex",
format!("--name-regex is not valid Rust regex syntax: {reason}"),
Some(CliDetails::CliArgument {
argument: "--name-regex".to_owned(),
value: frames.name_regex.clone(),
}),
EXIT_USAGE,
)
})
}
struct FramePreamble {
regex: Option<Regex>,
renderer: Renderer,
asset_paths: BTreeMap<String, String>,
asset_mimes: BTreeMap<String, String>,
}
fn prepare_frame_preamble(frames: &FramesCli) -> Result<FramePreamble, Failure> {
validate_report_options(&frames.report_cli())?;
validate_frame_limit(frames)?;
let regex = frame_name_regex(frames)?;
let renderer = Renderer::new();
let (asset_paths, asset_mimes) = parse_asset_declarations(&frames.asset, &frames.asset_mime)?;
let mut validation_assets = SupplementalAssets::builder();
for asset_id in asset_paths.keys() {
validation_assets = if let Some(media_type) = asset_mimes.get(asset_id) {
validation_assets.asset_with_media_type(asset_id, Vec::new(), media_type)
} else {
validation_assets.asset(asset_id, Vec::new())
};
}
let validation_assets = validation_assets
.build()
.map_err(|error| Failure::library(error.descriptor(), EXIT_USAGE))?;
build_frame_request(
frames,
frames.format.map_or(OutputFormat::Svg, OutputFormat::from),
validation_assets,
)?;
Ok(FramePreamble {
regex,
renderer,
asset_paths,
asset_mimes,
})
}
fn select_frames<'scene>(
scene: &'scene SceneDocument,
regex: Option<&Regex>,
) -> Vec<excalidraw_render::FrameRef<'scene>> {
scene
.frames()
.filter(|frame| {
regex.is_none_or(|regex| {
frame
.name()
.is_some_and(|name| !name.is_empty() && regex.is_match(name))
})
})
.collect()
}
fn frame_limit_exceeded(frames: &FramesCli) -> Failure {
Failure::cli(
"cli.frame-limit-exceeded",
"selected frame count exceeds --max-frames",
Some(CliDetails::CliArgument {
argument: "--max-frames".to_owned(),
value: Some(frames.max_frames.to_string()),
}),
EXIT_OPERATIONAL,
)
}
fn prepare_and_render_frames(frames: &FramesCli) -> Result<PreparedFrameBatch, Failure> {
if frames.output() == "-" {
return Err(Failure::cli(
"cli.invalid-arguments",
"frame artifact output must be a directory and cannot be -",
Some(CliDetails::CliArgument {
argument: "--output".to_owned(),
value: Some("-".to_owned()),
}),
EXIT_USAGE,
));
}
let FramePreamble {
regex,
renderer,
asset_paths,
asset_mimes,
} = prepare_frame_preamble(frames)?;
let report_path = frames.report_output.as_deref().unwrap_or("stderr");
validate_paths(&frames.input, frames.output(), report_path, &asset_paths)?;
let output_directory = Path::new(frames.output());
if path_entry_exists(output_directory) && !output_directory.is_dir() {
return Err(Failure::cli(
"cli.invalid-output-directory",
format!(
"frame output '{}' exists and is not a directory",
frames.output()
),
Some(CliDetails::CliArgument {
argument: "--output".to_owned(),
value: Some(frames.output().to_owned()),
}),
EXIT_USAGE,
)
.with_hint(
"pass a directory to frames -o, or drop `frames` to render the whole scene to one file",
));
}
if !output_directory.is_dir() {
if let Some(error) = output_directory
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.and_then(not_a_directory)
{
let error = DeliveryError::new(CliOperation::Create, &error);
let mut failure = Failure::cli(
"cli.output-write-failed",
error.message(&format!("output directory '{}'", frames.output())),
Some(CliDetails::Delivery {
role: DeliveryRole::Output,
operation: CliOperation::Create,
path: frames.output().to_owned(),
artifact_state: DeliveryState::None,
}),
EXIT_OPERATIONAL,
);
failure.hints.extend(error.hint(output_directory));
return Err(failure);
}
}
if report_path != "stderr" && destination_is_taken(Path::new(report_path)) && !frames.force {
return Err(destination_exists(DeliveryRole::Report, report_path));
}
let scene = read_scene(&frames.input)?;
let selected = select_frames(&scene, regex.as_ref());
if selected.is_empty() {
let failure = Failure::cli(
"cli.frame-no-match",
match &frames.name_regex {
Some(regex) => format!("no frames match --name-regex '{regex}'"),
None => "the scene has no frames".to_owned(),
},
None,
EXIT_OPERATIONAL,
);
return Err(if scene.frames().next().is_none() {
failure.with_hint("render the whole scene instead: `excalidraw-render INPUT -o FILE`")
} else {
failure
});
}
if selected.len() > frames.max_frames as usize {
return Err(frame_limit_exceeded(frames));
}
let basenames = frame_output_basenames(&selected, Some(OutputFormat::from(frames.format())));
let destinations = basenames
.iter()
.map(|basename| output_directory.join(basename))
.collect::<Vec<_>>();
validate_frame_paths(
&frames.input,
&destinations,
frames.report_output.as_deref().unwrap_or("stderr"),
&asset_paths,
)?;
let destination_collisions = destinations
.iter()
.map(|destination| destination_is_taken(destination) && !frames.force)
.collect::<Vec<_>>();
let limits = ResourceLimits::default();
let mut assets_builder = SupplementalAssets::builder();
let mut total_asset_bytes = 0_u64;
for (asset_id, path) in &asset_paths {
let aggregate_remaining = limits
.max_total_asset_bytes()
.saturating_sub(total_asset_bytes);
let bytes = match read_bounded_asset(
path,
asset_id,
limits.max_asset_bytes(),
limits.max_total_asset_bytes(),
aggregate_remaining,
) {
Ok(bytes) => bytes,
Err(error) => {
if let Some(destination) = destinations
.iter()
.zip(&destination_collisions)
.find_map(|(destination, collision)| collision.then_some(destination))
{
return Err(destination_exists(
DeliveryRole::Output,
&destination.to_string_lossy(),
));
}
return Err(error);
}
};
total_asset_bytes =
total_asset_bytes.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
assets_builder = if let Some(media_type) = asset_mimes.get(asset_id) {
assets_builder.asset_with_media_type(asset_id, bytes, media_type)
} else {
assets_builder.asset(asset_id, bytes)
};
}
let assets = assets_builder
.build()
.map_err(|error| Failure::library(error.descriptor(), EXIT_USAGE))?;
let request = build_frame_request(frames, OutputFormat::from(frames.format()), assets)?;
let mut cursor = match renderer.render_frames(&scene, selected.iter().copied(), &request) {
Ok(cursor) => cursor,
Err(error) => {
if let Some(destination) = destinations
.iter()
.zip(&destination_collisions)
.find_map(|(destination, collision)| collision.then_some(destination))
{
return Err(destination_exists(
DeliveryRole::Output,
&destination.to_string_lossy(),
));
}
return Err(
if error.kind() == FrameBatchErrorKind::SharedPreparationFailed {
Failure::library(error.descriptor(), EXIT_OPERATIONAL)
} else {
Failure::cli(
"cli.invalid-frame-selection",
error.to_string(),
None,
EXIT_USAGE,
)
},
);
}
};
let staging_parent = if output_directory.is_dir() {
output_directory
} else {
output_directory
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or(Path::new("."))
};
let mut entries = Vec::new();
let mut had_failure = false;
for (((frame, destination), initial_collision), rendered) in selected
.iter()
.zip(destinations.iter())
.zip(destination_collisions)
.zip(cursor.by_ref())
{
debug_assert_eq!(frame.ordinal(), rendered.frame().ordinal());
let diagnostics = rendered.target_diagnostics().to_vec();
let mut entry = FrameCliEntry {
ordinal: frame.ordinal(),
kind: frame.kind().as_str(),
name: frame.name().map(str::to_owned),
output_path: destination.to_string_lossy().into_owned(),
artifact: None,
error: None,
diagnostics,
delivery_state: DeliveryState::None,
};
match rendered.into_result() {
Ok(outcome) => {
let bytes = outcome.artifact().bytes();
entry.artifact = Some(ArtifactDescriptor {
media_type: outcome.artifact().media_type(),
byte_length: bytes.len(),
sha256: sha256(bytes),
});
if initial_collision || destination_is_taken(destination) && !frames.force {
had_failure = true;
entry.error = Some(frame_destination_error(destination));
entries.push((entry, None));
continue;
}
match StagedFile::new_at(staging_parent, destination, bytes, frames.force) {
Ok(staged) => entries.push((entry, Some(staged))),
Err(error) => {
had_failure = true;
entry.error = Some(frame_delivery_error(&error, &entry.output_path));
entries.push((entry, None));
}
}
}
Err(error) => {
had_failure = true;
entry.error = Some(if initial_collision {
frame_destination_error(destination)
} else {
ReportError::Library(error.descriptor())
});
entries.push((entry, None));
}
}
}
let global_diagnostics = cursor.global_diagnostics().to_vec();
Ok(PreparedFrameBatch {
entries,
global_diagnostics,
had_failure,
})
}
fn frame_destination_error(destination: &Path) -> ReportError {
*destination_exists(DeliveryRole::Output, &destination.to_string_lossy()).error
}
fn frame_delivery_error(error: &DeliveryError, path: &str) -> ReportError {
frame_cli_error(
"cli.output-write-failed",
error.message(&format!("output '{path}'")),
CliDetails::Delivery {
role: DeliveryRole::Output,
operation: error.operation,
path: path.to_owned(),
artifact_state: DeliveryState::None,
},
)
}
fn build_frame_request(
frames: &FramesCli,
format: OutputFormat,
assets: SupplementalAssets,
) -> Result<FrameRenderRequest, Failure> {
let sizing = frames
.max_dimension
.map(Sizing::MaxDimension)
.unwrap_or_else(|| Sizing::Scale(frames.scale.unwrap_or(1.0)));
let mut builder = FrameRenderRequest::builder(format)
.include_background(!frames.no_background)
.sizing(sizing)
.color_mode(if frames.dark_mode {
ColorMode::Dark
} else {
ColorMode::Light
})
.metadata_options(
MetadataOptions::default()
.with_embedded_scene(frames.embed_scene)
.with_provenance(frames.embed_provenance)
.with_diagnostic_summary(frames.embed_diagnostics),
)
.supplemental_assets(assets)
.resource_limits(ResourceLimits::default());
if let Some(fallback) = frames.pdf_raster_fallback {
builder = builder.pdf_options(PdfOptions::default().with_raster_fallback(match fallback {
PdfFallbackArg::AllowIsolated => PdfRasterFallback::AllowIsolated,
PdfFallbackArg::Forbid => PdfRasterFallback::Forbid,
}));
}
builder
.build()
.map_err(|error| Failure::library(error.descriptor(), EXIT_USAGE))
}
fn validate_frame_paths(
input: &str,
outputs: &[PathBuf],
report: &str,
assets: &BTreeMap<String, String>,
) -> Result<(), Failure> {
for output in outputs {
let output = output.to_string_lossy();
if input != "-" && same_path(input, &output) {
return Err(path_conflict([
("input", None, input),
("output", None, &output),
]));
}
if report != "stderr" && same_path(report, &output) {
return Err(path_conflict([
("report", None, report),
("output", None, &output),
]));
}
for (id, asset) in assets {
if same_path(asset, &output) {
return Err(path_conflict([
("asset", Some(id.as_str()), asset.as_str()),
("output", None, &output),
]));
}
}
}
Ok(())
}
fn frame_output_basenames(
frames: &[excalidraw_render::FrameRef<'_>],
format: Option<OutputFormat>,
) -> Vec<String> {
let mut used = HashSet::new();
let mut next_suffixes = HashMap::new();
let mut basenames = Vec::with_capacity(frames.len());
for frame in frames {
let normalized_name = frame.name().map(|name| name.nfc().collect::<String>());
let mut root = normalized_name
.as_deref()
.map(portable_frame_slug)
.filter(|name| !name.is_empty())
.unwrap_or_else(|| format!("frame-{}", frame.ordinal()));
if is_reserved_device_name(&root) {
root.insert(0, '_');
}
let stable_suffix = if root.len() > 120 {
let digest = sha256(normalized_name.as_deref().unwrap_or(&root).as_bytes());
format!("-{}", &digest["sha256:".len().."sha256:".len() + 8])
} else {
String::new()
};
let basename = unique_basename(&root, &stable_suffix, &mut used, &mut next_suffixes);
basenames.push(match format {
Some(format) => format!("{basename}.{}", format.as_str()),
None => basename,
});
}
basenames
}
fn unique_basename(
root: &str,
stable_suffix: &str,
used: &mut HashSet<String>,
next_suffixes: &mut HashMap<(String, u32), u64>,
) -> String {
let stem = |reserved_bytes: usize| {
let mut stem = truncate_basename(root, 120 - stable_suffix.len() - reserved_bytes);
stem.push_str(stable_suffix);
stem
};
let basename = stem(0);
if used.insert(folded_name(&basename)) {
return basename;
}
let mut first = 2_u64;
let mut digits = 1_u32;
loop {
let last = 10_u64.saturating_pow(digits) - 1;
let stem = stem(1 + digits as usize);
let next = next_suffixes
.entry((folded_name(&stem), digits))
.or_insert(first);
while *next <= last {
let basename = format!("{stem}-{next}");
*next += 1;
if used.insert(folded_name(&basename)) {
return basename;
}
}
first = last + 1;
digits += 1;
}
}
fn folded_name(basename: &str) -> String {
unicase::UniCase::new(basename.nfc().collect::<String>())
.to_folded_case()
.nfc()
.collect()
}
fn portable_frame_slug(name: &str) -> String {
let mut slug = String::new();
let mut separator = false;
for character in name.chars() {
if character.is_alphanumeric() {
if separator && !slug.is_empty() {
slug.push('-');
}
slug.push(character);
separator = false;
} else {
separator = true;
}
}
slug
}
fn truncate_basename(value: &str, max_bytes: usize) -> String {
let mut end = max_bytes.min(value.len());
while !value.is_char_boundary(end) {
end = end.saturating_sub(1);
}
value[..end].to_owned()
}
fn is_reserved_device_name(value: &str) -> bool {
let value = value.to_ascii_uppercase();
matches!(value.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| value
.strip_prefix("COM")
.or_else(|| value.strip_prefix("LPT"))
.is_some_and(|suffix| {
matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
})
}
fn frame_cli_error(
code: &'static str,
message: impl Into<String>,
details: CliDetails,
) -> ReportError {
ReportError::Cli(CliError {
family: "cli",
category: None,
code,
message: message.into(),
format: None,
element_id: None,
json_pointer: None,
details: Some(details),
})
}
fn deliver_frame_batch(
cli: &Cli,
format: OutputFormat,
mut entries: Vec<(FrameCliEntry, Option<StagedFile>)>,
global_diagnostics: Vec<CompatibilityDiagnostic>,
mut had_failure: bool,
allow_partial: bool,
) -> u8 {
let publish_artifacts = !had_failure || allow_partial;
if !publish_artifacts {
for (_, staged) in &mut entries {
staged.take();
}
} else {
for (entry, staged) in &mut entries {
if staged.is_some() {
debug_assert!(matches!(entry.delivery_state, DeliveryState::None));
entry.delivery_state = DeliveryState::Complete;
}
}
}
let (status, _) = frame_batch_delivery(&entries, had_failure);
let mut report_bytes = frame_batch_report_bytes(
cli.report_format,
format,
status,
&global_diagnostics,
&entries,
HumanReport::new(cli.verbose, cli.max_dimension, Some(format)),
);
let mut staged_report = match stage_report_file(cli, &report_bytes) {
Ok(staged) => staged,
Err(error) => {
return emergency_report_failure(
cli,
format,
&error,
DeliveryState::None,
frame_batch_diagnostics(&global_diagnostics, &entries),
)
}
};
for (entry, staged) in &mut entries {
if staged.is_some() {
entry.delivery_state = DeliveryState::None;
}
}
if publish_artifacts {
let output = Path::new(cli.output.as_deref().expect("frame output validated"));
let created = if output.is_dir() {
Ok(())
} else {
fs::create_dir(output)
};
if let Err(error) = created {
let error = DeliveryError::new(CliOperation::Create, &error);
let message = error.message(&format!("output directory '{}'", output.display()));
for (entry, staged) in &mut entries {
if staged.take().is_some() {
entry.delivery_state = DeliveryState::None;
entry.error = Some(frame_cli_error(
"cli.output-write-failed",
message.clone(),
CliDetails::Delivery {
role: DeliveryRole::Output,
operation: CliOperation::Create,
path: entry.output_path.clone(),
artifact_state: DeliveryState::None,
},
));
}
}
had_failure = true;
} else {
let mut commit_failed = false;
for (entry, staged) in &mut entries {
let Some(staged) = staged.take() else {
continue;
};
if commit_failed {
had_failure = true;
entry.error = Some(frame_cli_error(
"cli.output-write-failed",
"frame artifact was not delivered after an earlier commit failure",
CliDetails::Delivery {
role: DeliveryRole::Output,
operation: if cli.force {
CliOperation::Replace
} else {
CliOperation::Create
},
path: entry.output_path.clone(),
artifact_state: DeliveryState::None,
},
));
continue;
}
match staged.deliver() {
Ok(()) => entry.delivery_state = DeliveryState::Complete,
Err(error) => {
had_failure = true;
entry.delivery_state = DeliveryState::None;
entry.error = Some(frame_delivery_error(&error, &entry.output_path));
if !allow_partial {
commit_failed = true;
}
}
}
}
}
for (entry, staged) in &mut entries {
if staged.take().is_some() {
entry.delivery_state = DeliveryState::None;
}
}
}
let (status, artifact_state) = frame_batch_delivery(&entries, had_failure);
let final_report_bytes = frame_batch_report_bytes(
cli.report_format,
format,
status,
&global_diagnostics,
&entries,
HumanReport::new(cli.verbose, cli.max_dimension, Some(format)),
);
if final_report_bytes != report_bytes {
staged_report.take();
report_bytes = final_report_bytes;
staged_report = match stage_report_file(cli, &report_bytes) {
Ok(staged) => staged,
Err(error) => {
return emergency_report_failure(
cli,
format,
&error,
artifact_state,
frame_batch_diagnostics(&global_diagnostics, &entries),
)
}
};
}
let report_exit = deliver_frame_report(
cli,
format,
&report_bytes,
staged_report,
artifact_state,
frame_batch_diagnostics(&global_diagnostics, &entries),
);
if report_exit != 0 {
return report_exit;
}
if had_failure {
EXIT_OPERATIONAL
} else {
0
}
}
fn frame_batch_diagnostics(
global: &[CompatibilityDiagnostic],
entries: &[(FrameCliEntry, Option<StagedFile>)],
) -> Vec<CompatibilityDiagnostic> {
global
.iter()
.chain(
entries
.iter()
.flat_map(|(entry, _)| entry.diagnostics.iter()),
)
.cloned()
.collect()
}
fn frame_batch_delivery(
entries: &[(FrameCliEntry, Option<StagedFile>)],
had_failure: bool,
) -> (&'static str, DeliveryState) {
let complete = entries
.iter()
.filter(|(entry, _)| matches!(entry.delivery_state, DeliveryState::Complete))
.count();
let possibly_partial = entries
.iter()
.any(|(entry, _)| matches!(entry.delivery_state, DeliveryState::PossiblyPartial));
let status = if had_failure {
if complete == 0 && !possibly_partial {
"failure"
} else {
"partial"
}
} else {
"success"
};
let artifact_state = if complete == entries.len() && !possibly_partial {
DeliveryState::Complete
} else if complete == 0 && !possibly_partial {
DeliveryState::None
} else {
DeliveryState::PossiblyPartial
};
(status, artifact_state)
}
fn frame_batch_report_bytes(
mode: ReportFormat,
format: OutputFormat,
status: &str,
global_diagnostics: &[CompatibilityDiagnostic],
entries: &[(FrameCliEntry, Option<StagedFile>)],
human: HumanReport,
) -> Vec<u8> {
match mode {
ReportFormat::None if status == "success" => Vec::new(),
ReportFormat::None => frame_batch_error_line(entries),
ReportFormat::Human => {
let diagnostics = frame_batch_diagnostics(global_diagnostics, entries);
let mut text = format!(
"{}{}",
human.notes(),
human::diagnostics(&diagnostics, human.verbose)
);
let mut reported = false;
let mut codes = Vec::new();
let mut hints = Vec::new();
for (entry, _) in entries {
if let Some(error) = &entry.error {
reported = true;
text.push_str(&human::error_line(
&frame_error_message(entry, error),
error.code(),
));
match error.specific_hint() {
Some(hint) if !hints.contains(&hint) => hints.push(hint),
Some(_) => {}
None if !codes.contains(&error.code()) => codes.push(error.code()),
None => {}
}
}
}
if status != "success" && !reported {
text.push_str(&human::error_line(
"frame batch delivery failed",
"cli.output-write-failed",
));
}
for hint in hints {
text.push_str(&format!("hint: {hint}\n"));
}
let existing = entries
.iter()
.filter_map(|(entry, _)| entry.error.as_ref())
.filter(|error| error.code() == "cli.destination-exists")
.count();
for code in codes {
if code == "cli.destination-exists" && existing > 1 {
text.push_str(&format!(
"hint: use --force to overwrite the {existing} existing frame files\n"
));
} else {
text.push_str(&human::hint_lines(code, std::iter::empty()));
}
}
text.into_bytes()
}
ReportFormat::Json => {
let artifact_state = frame_batch_delivery(entries, status != "success").1;
json_line(&FrameBatchReport {
schema_version: 1,
command: ReportCommand::FramesExport,
status,
renderer_version: env!("CARGO_PKG_VERSION"),
excalidraw_release: excalidraw_release(),
format,
delivery_state: artifact_state,
diagnostics: global_diagnostics,
frames: entries,
})
}
}
}
fn frame_error_message(entry: &FrameCliEntry, error: &ReportError) -> String {
match error {
ReportError::Cli(_) => format!("frame {}: {}", entry.ordinal, error.human_message()),
ReportError::Library(_) => format!(
"frame {} ({}): {}",
entry.ordinal,
entry.output_path,
error.human_message()
),
}
}
fn frame_batch_error_line(entries: &[(FrameCliEntry, Option<StagedFile>)]) -> Vec<u8> {
entries
.iter()
.find_map(|(entry, _)| Some((entry, entry.error.as_ref()?)))
.map_or_else(
|| human::error_line("frame batch delivery failed", "cli.output-write-failed"),
|(entry, error)| human::error_line(&frame_error_message(entry, error), error.code()),
)
.into_bytes()
}
fn deliver_frame_report(
cli: &Cli,
format: OutputFormat,
bytes: &[u8],
staged_report: Option<StagedFile>,
artifact_state: DeliveryState,
diagnostics: Vec<CompatibilityDiagnostic>,
) -> u8 {
let report = report_output(cli);
if let Some(staged) = staged_report {
return match staged.deliver() {
Ok(()) => 0,
Err(error) => {
emergency_report_failure(cli, format, &error, artifact_state, diagnostics)
}
};
}
debug_assert_eq!(report, "stderr");
if report == "stderr" {
return if bytes.is_empty() || write_stream(&mut io::stderr().lock(), bytes).is_ok() {
0
} else {
EXIT_OPERATIONAL
};
}
EXIT_OPERATIONAL
}
#[cfg(test)]
mod tests {
use super::{
frame_batch_report_bytes, frame_list_json_line, frame_output_basenames,
is_reserved_device_name, portable_frame_slug, truncate_basename, FrameCliEntry,
FrameListEntry,
};
use crate::tests::{
invalid_json_error, missing_image_diagnostics, INVALID_JSON_ERROR_JSON,
MISSING_IMAGE_DIAGNOSTIC_JSON, TRICKY, TRICKY_JSON,
};
use crate::{ArtifactDescriptor, DeliveryState, ReportError, ReportFormat};
use excalidraw_render::{OutputFormat, SceneDocument};
fn utf8(bytes: Vec<u8>) -> String {
String::from_utf8(bytes).expect("the report is UTF-8")
}
#[test]
fn a_frame_list_report_carries_every_entry() {
let entries = [
FrameListEntry {
ordinal: 1,
kind: "frame",
name: None,
basename: "frame-1".to_owned(),
},
FrameListEntry {
ordinal: 2,
kind: "magicframe",
name: Some(TRICKY.to_owned()),
basename: "b".to_owned(),
},
];
let frames_json = [
r#""frames":[{"ordinal":1,"kind":"frame","name":null,"#,
r#""output_basename":"frame-1"},{"ordinal":2,"kind":"magicframe","name":"#,
TRICKY_JSON,
r#","output_basename":"b"}]}"#,
"\n",
]
.concat();
let version = env!("CARGO_PKG_VERSION");
assert_eq!(
utf8(frame_list_json_line(&entries)),
format!(
r#"{{"schema_version":1,"command":"frames.list","status":"success","renderer_version":"{version}","excalidraw_release":"v0.18.1",{frames_json}"#
)
);
assert_eq!(
utf8(frame_list_json_line(&[])),
format!(
r#"{{"schema_version":1,"command":"frames.list","status":"success","renderer_version":"{version}","excalidraw_release":"v0.18.1","frames":[]}}"#
) + "\n"
);
}
#[test]
fn a_frame_batch_report_carries_per_frame_artifacts_errors_and_diagnostics() {
let diagnostics = missing_image_diagnostics();
let entries = vec![
(
FrameCliEntry {
ordinal: 1,
kind: "frame",
name: Some(TRICKY.to_owned()),
output_path: "out/a.svg".to_owned(),
artifact: Some(ArtifactDescriptor {
media_type: "image/svg+xml",
byte_length: 7,
sha256: "sha256:def".to_owned(),
}),
error: None,
diagnostics: diagnostics.clone(),
delivery_state: DeliveryState::Complete,
},
None,
),
(
FrameCliEntry {
ordinal: 2,
kind: "magicframe",
name: None,
output_path: TRICKY.to_owned(),
artifact: None,
error: Some(ReportError::Library(invalid_json_error())),
diagnostics: Vec::new(),
delivery_state: DeliveryState::None,
},
None,
),
];
let version = env!("CARGO_PKG_VERSION");
assert_eq!(
utf8(frame_batch_report_bytes(
ReportFormat::Json,
OutputFormat::Svg,
"partial",
&diagnostics,
&entries,
crate::human::HumanReport::PLAIN,
)),
[
r#"{"schema_version":1,"command":"frames.export","status":"partial","#,
r#""renderer_version":""#,
version,
r#"","excalidraw_release":"v0.18.1","format":"svg","#,
r#""delivery_state":"possibly_partial","diagnostics":["#,
MISSING_IMAGE_DIAGNOSTIC_JSON,
r#"],"frames":[{"ordinal":1,"kind":"frame","name":"#,
TRICKY_JSON,
r#","output_path":"out/a.svg","#,
r#""artifact":{"media_type":"image/svg+xml","byte_length":7,"sha256":"sha256:def"},"#,
r#""error":null,"diagnostics":["#,
MISSING_IMAGE_DIAGNOSTIC_JSON,
r#"],"delivery_state":"complete"},"#,
r#"{"ordinal":2,"kind":"magicframe","name":null,"output_path":"#,
TRICKY_JSON,
r#","artifact":null,"error":"#,
INVALID_JSON_ERROR_JSON,
r#","diagnostics":[],"delivery_state":"none"}]}"#,
"\n",
]
.concat()
);
}
fn scene_with_frame_names(names: &[Option<&str>]) -> SceneDocument {
let elements = names
.iter()
.enumerate()
.map(|(index, name)| {
let name = match name {
Some(name) => serde_json::to_string(name).expect("name serializes"),
None => "null".to_owned(),
};
format!(
r#"{{"id":"frame-{index}","type":"frame","name":{name},"x":{x},"y":0,"width":20,"height":20,"updated":1}}"#,
x = index * 30
)
})
.collect::<Vec<_>>()
.join(",");
SceneDocument::parse(
format!(
r#"{{"type":"excalidraw","version":2,"elements":[{elements}],"appState":{{}},"files":{{}}}}"#
)
.as_bytes(),
)
.expect("frame scene is admitted")
}
fn svg_basenames(names: &[Option<&str>]) -> Vec<String> {
let scene = scene_with_frame_names(names);
let frames = scene.frames().collect::<Vec<_>>();
frame_output_basenames(&frames, Some(OutputFormat::Svg))
}
#[test]
fn frame_basenames_fold_case_and_normalize_unicode_before_deduplicating() {
assert_eq!(
svg_basenames(&[Some("Straße"), Some("STRASSE"), Some("strasse")]),
["Straße.svg", "STRASSE-2.svg", "strasse-3.svg"]
);
assert_eq!(
svg_basenames(&[Some("caf\u{e9}"), Some("cafe\u{301}")]),
["caf\u{e9}.svg", "caf\u{e9}-2.svg"]
);
}
#[test]
fn long_frame_names_are_truncated_to_120_bytes_with_a_stable_hash_suffix() {
let long_name = "A".repeat(140);
let basenames = svg_basenames(&[Some(&long_name), Some(&long_name)]);
assert_eq!(
basenames,
[
format!("{}-88e685e0.svg", "A".repeat(111)),
format!("{}-88e685e0-2.svg", "A".repeat(109)),
]
);
for basename in &basenames {
let stem = basename
.strip_suffix(".svg")
.expect("the basename carries the SVG extension");
assert!(stem.len() <= 120, "{basename} exceeds the component budget");
}
let spaced = format!("{} B", "A".repeat(139));
let dashed = format!("{}-B", "A".repeat(139));
let distinct = svg_basenames(&[Some(&spaced), Some(&dashed)]);
assert_ne!(distinct[0], distinct[1]);
assert!(!distinct[1].ends_with("-2.svg"), "{}", distinct[1]);
let above_first = format!("{}\u{1ae0}\u{316}", "A".repeat(140));
let below_first = format!("{}\u{316}\u{1ae0}", "A".repeat(140));
let equivalent = svg_basenames(&[Some(&above_first), Some(&below_first)]);
let hash = &equivalent[0][111..120];
assert_eq!(equivalent[1], format!("{}{hash}-2.svg", "A".repeat(109)));
}
#[test]
fn reserved_device_names_are_prefixed_and_unnamed_frames_use_their_ordinal() {
assert_eq!(
svg_basenames(&[
Some("CON"),
Some("com1"),
Some("Lpt9"),
Some("COM10"),
Some("Console")
]),
[
"_CON.svg",
"_com1.svg",
"_Lpt9.svg",
"COM10.svg",
"Console.svg"
]
);
assert_eq!(
svg_basenames(&[None, Some("!!!"), Some("Named"), Some(" ")]),
["frame-1.svg", "frame-2.svg", "Named.svg", "frame-4.svg"]
);
}
#[test]
fn list_mode_without_a_format_reports_bare_stems() {
let scene = scene_with_frame_names(&[Some("Overview"), Some("Detail")]);
let frames = scene.frames().collect::<Vec<_>>();
assert_eq!(
frame_output_basenames(&frames, None),
["Overview", "Detail"]
);
assert_eq!(
frame_output_basenames(&frames, Some(OutputFormat::Png)),
["Overview.png", "Detail.png"]
);
}
fn reference_frame_output_basenames(
frames: &[excalidraw_render::FrameRef<'_>],
format: Option<OutputFormat>,
) -> Vec<String> {
use std::collections::BTreeSet;
use unicode_normalization::UnicodeNormalization as _;
let mut used = BTreeSet::new();
let mut basenames = Vec::with_capacity(frames.len());
for frame in frames {
let normalized_name = frame.name().map(|name| name.nfc().collect::<String>());
let mut root = normalized_name
.as_deref()
.map(portable_frame_slug)
.filter(|name| !name.is_empty())
.unwrap_or_else(|| format!("frame-{}", frame.ordinal()));
if is_reserved_device_name(&root) {
root.insert(0, '_');
}
let stable_suffix = if root.len() > 120 {
let digest = crate::sha256(normalized_name.as_deref().unwrap_or(&root).as_bytes());
format!("-{}", &digest["sha256:".len().."sha256:".len() + 8])
} else {
String::new()
};
let mut suffix = 1_u32;
loop {
let collision_suffix = (suffix > 1).then(|| format!("-{suffix}"));
let reserved_bytes =
stable_suffix.len() + collision_suffix.as_deref().map_or(0, str::len);
let mut basename = truncate_basename(&root, 120 - reserved_bytes);
basename.push_str(&stable_suffix);
if let Some(collision_suffix) = collision_suffix {
basename.push_str(&collision_suffix);
}
let key = unicase::UniCase::new(basename.nfc().collect::<String>())
.to_folded_case()
.nfc()
.collect::<String>();
if used.insert(key) {
basenames.push(match format {
Some(format) => format!("{basename}.{}", format.as_str()),
None => basename,
});
break;
}
suffix += 1;
}
}
basenames
}
fn assert_matches_reference(names: &[Option<&str>]) {
let scene = scene_with_frame_names(names);
let frames = scene.frames().collect::<Vec<_>>();
for format in [None, Some(OutputFormat::Png)] {
let actual = frame_output_basenames(&frames, format);
let expected = reference_frame_output_basenames(&frames, format);
if let Some(index) = (0..expected.len()).find(|&i| actual[i] != expected[i]) {
panic!(
"frame {} ({:?}) is named {:?}, the reference names it {:?}",
index + 1,
names[index],
actual[index],
expected[index]
);
}
assert_eq!(actual.len(), expected.len());
}
}
fn case_variants(word: &str, count: usize) -> Vec<String> {
(0..count)
.map(|mask| {
word.chars()
.enumerate()
.map(|(bit, character)| {
if mask >> (bit % usize::BITS as usize) & 1 == 1 {
character.to_ascii_uppercase()
} else {
character
}
})
.collect()
})
.collect()
}
#[test]
fn frame_basename_deduplication_matches_the_reference_on_adversarial_names() {
let mut cases: Vec<Vec<Option<String>>> = Vec::new();
let some = |names: Vec<String>| names.into_iter().map(Some).collect::<Vec<_>>();
cases.push(some(case_variants("abcdefghijk", 2048)));
cases.push(some(
[
"A-2", "A", "A", "a-3", "A", "A-5", "A", "A", "a", "A-4", "A-10", "A",
]
.iter()
.map(|name| (*name).to_owned())
.chain(std::iter::repeat_n("A".to_owned(), 30))
.chain((2..40).map(|suffix| format!("A-{suffix}")))
.chain(std::iter::repeat_n("a".to_owned(), 30))
.collect(),
));
for (unit, count) in [("x", 118), ("x", 119), ("x", 120), ("é", 59), ("é", 60)] {
let root = unit.repeat(count);
let mut names = vec![root.clone(); 25];
names.push(format!("{}-12", unit.repeat(count.saturating_sub(4))));
names.push(format!("{}-3", &root[..root.len() - 2 * unit.len()]));
names.extend(vec![root.clone(); 15]);
names.extend(vec![root.to_uppercase(); 15]);
cases.push(some(names));
}
let long = "Long Name ".repeat(14);
let mut names = vec![long.clone(); 12];
names.extend(case_variants(&long, 16));
names.extend(vec![long.to_uppercase(); 12]);
cases.push(some(names));
cases.push(
[
Some("Straße"),
Some("STRASSE"),
Some("strasse-2"),
Some("Strasse"),
Some("caf\u{e9}"),
Some("cafe\u{301}"),
Some("CAF\u{c9}-2"),
Some("CAFE\u{301}"),
Some("CON"),
Some("_con"),
Some("con"),
None,
Some("frame-12"),
Some("Frame-13"),
None,
Some("!!!"),
None,
Some("frame-15-2"),
Some("FRAME-15"),
]
.iter()
.map(|name| name.map(str::to_owned))
.chain(std::iter::repeat_n(None, 8))
.collect(),
);
let alphabet = [
"a", "A", "-", "2", "1", "ß", "SS", "ss", "é", "e\u{301}", " ", "x", "_",
];
let mut state = 0x9e37_79b9_7f4a_7c15_u64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
for _ in 0..4 {
let names = (0..1500)
.map(|_| {
let length = (next() % 6) as usize;
(0..length)
.map(|_| alphabet[(next() % alphabet.len() as u64) as usize])
.collect::<String>()
})
.collect::<Vec<_>>();
cases.push(some(names));
}
for case in &cases {
let names = case.iter().map(Option::as_deref).collect::<Vec<_>>();
assert_matches_reference(&names);
}
}
#[test]
fn the_largest_batch_of_one_name_is_named_in_one_pass() {
let variants = case_variants("abcdefghijklmn", 16_384);
let names = variants
.iter()
.map(|name| Some(name.as_str()))
.collect::<Vec<_>>();
let scene = scene_with_frame_names(&names);
let frames = scene.frames().collect::<Vec<_>>();
let basenames = frame_output_basenames(&frames, None);
for (index, (basename, variant)) in basenames.iter().zip(&variants).enumerate() {
let expected = match index {
0 => variant.clone(),
_ => format!("{variant}-{}", index + 1),
};
assert_eq!(basename, &expected);
}
}
#[test]
fn a_portable_slug_keeps_alphanumerics_and_joins_runs_with_one_dash() {
assert_eq!(portable_frame_slug("Hello, World!"), "Hello-World");
assert_eq!(
portable_frame_slug(" leading and trailing "),
"leading-and-trailing"
);
assert_eq!(
portable_frame_slug("a/b\\c:d*e?f\"g<h>i|j"),
"a-b-c-d-e-f-g-h-i-j"
);
assert_eq!(portable_frame_slug("日本語 タイトル"), "日本語-タイトル");
assert_eq!(portable_frame_slug("..."), "");
assert_eq!(portable_frame_slug(""), "");
}
#[test]
fn truncation_never_splits_a_character() {
assert_eq!(truncate_basename("abcdef", 3), "abc");
assert_eq!(truncate_basename("abc", 10), "abc");
assert_eq!(truncate_basename("ééé", 3), "é");
assert_eq!(truncate_basename("ééé", 4), "éé");
assert_eq!(truncate_basename("日本", 3), "日");
assert_eq!(truncate_basename("abc", 0), "");
}
#[test]
fn reserved_device_names_match_the_windows_set_case_insensitively() {
for name in [
"CON", "con", "PRN", "AUX", "NUL", "COM1", "com9", "LPT1", "lpt9",
] {
assert!(is_reserved_device_name(name), "{name}");
}
for name in [
"",
"CON1",
"COM0",
"COM10",
"LPT",
"CONSOLE",
"NULL",
"AUXILIARY",
] {
assert!(!is_reserved_device_name(name), "{name}");
}
}
}