use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use stenoxide_core::image_io::buffer::CoverSource;
use stenoxide_core::image_io::validate::{load_and_validate, probe_geometry};
use crate::progress::Progress;
use crate::{container_capacity, terminal_renders_unicode, ScanArgs};
const PNG_EXTENSION: &str = "png";
const BYTES_PER_KIB: f64 = 1024.0;
const PATH_HEADING: &str = "PATH";
const SIZE_HEADING: &str = "SIZE";
const CAPACITY_HEADING: &str = "PAYLOAD*";
const CAPACITY_AND_REASON_HEADING: &str = "PAYLOAD* / REASON";
const REASON_HEADING: &str = "REASON";
const NEXT_STEP_HINT: &str = "\n\
Hide a message in one of them:\n\
stenoxide embed --input <file above> --output stego.png\n";
const NOTHING_USABLE_HINT: &str = "\n\
None of these can be used as a container.\n\
stenoxide generate builds one around your message instead. It is a last\n\
resort: the container does not hide that it was generated, only which of\n\
several generated containers carries anything.\n";
enum Verdict {
Usable {
dimensions: (u32, u32),
capacity_bytes: usize,
},
Unusable {
reason: &'static str,
dimensions: Option<(u32, u32)>,
},
}
struct Entry {
path: PathBuf,
verdict: Verdict,
}
pub fn run(args: &ScanArgs) -> Result<(), String> {
let files = collect_files(&args.path, args.recursive)?;
let probed: Vec<(PathBuf, Option<u64>)> = files
.into_iter()
.map(|path| {
let pixels = candidate_pixels(&path);
(path, pixels)
})
.collect();
let total_megapixels: u64 = probed
.iter()
.filter_map(|(_, pixels)| *pixels)
.map(megapixels)
.sum();
let progress = Progress::new(
&format!(
"Analysing {} of {} files",
probed.iter().filter(|(_, pixels)| pixels.is_some()).count(),
probed.len()
),
total_megapixels,
);
let entries: Vec<Entry> = probed
.into_iter()
.map(|(path, pixels)| {
if let Some(pixels) = pixels {
progress.set_detail(&file_name(&path));
let entry = examine(path);
progress.advance(megapixels(pixels));
entry
} else {
examine(path)
}
})
.collect();
progress.finish();
let report = if args.json {
render_json(&entries)
} else {
render_listing(&args.path, &entries, args.all)
};
print!("{report}");
Ok(())
}
fn candidate_pixels(path: &Path) -> Option<u64> {
let is_png = path
.extension()
.map(|extension| extension.to_string_lossy().to_lowercase() == PNG_EXTENSION)
.unwrap_or(false);
if !is_png {
return None;
}
probe_geometry(path)
.ok()
.map(|geometry| geometry.pixel_count())
}
fn megapixels(pixels: u64) -> u64 {
pixels.div_ceil(1024 * 1024)
}
fn file_name(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
fn collect_files(argument: &str, recursive: bool) -> Result<Vec<PathBuf>, String> {
let path = Path::new(argument);
if path.is_file() {
return Ok(vec![path.to_path_buf()]);
}
if path.is_dir() {
let mut found = BTreeSet::new();
walk(path, recursive, &mut found)?;
return Ok(found.into_iter().collect());
}
let matched = glob_matches(argument, recursive)?;
if matched.is_empty() {
return Err(format!(
"Error: {argument} does not name a file, a directory or any matching path."
));
}
Ok(matched)
}
fn walk(directory: &Path, recursive: bool, found: &mut BTreeSet<PathBuf>) -> Result<(), String> {
let entries = std::fs::read_dir(directory)
.map_err(|err| format!("Error: cannot read {}: {err}", directory.display()))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if recursive {
walk(&path, recursive, found)?;
}
} else {
found.insert(path);
}
}
Ok(())
}
fn glob_matches(pattern: &str, recursive: bool) -> Result<Vec<PathBuf>, String> {
let (root, rest) = split_pattern(pattern);
let mut current = vec![root];
for component in rest {
let mut next = BTreeSet::new();
for directory in ¤t {
if component.as_str() == "**" {
next.insert(directory.clone());
collect_directories(directory, &mut next)?;
continue;
}
let entries = match std::fs::read_dir(directory) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if matches_component(&name, &component) {
next.insert(path);
}
}
}
current = next.into_iter().collect();
}
let mut files = BTreeSet::new();
for path in current {
if path.is_dir() {
walk(&path, recursive, &mut files)?;
} else if path.is_file() {
files.insert(path);
}
}
Ok(files.into_iter().collect())
}
fn split_pattern(pattern: &str) -> (PathBuf, Vec<String>) {
let components: Vec<&str> = pattern.split(['/', '\\']).collect();
let fixed = components
.iter()
.position(|component| component.contains(['*', '?']))
.unwrap_or(components.len());
let root = match &components[..fixed] {
[] => PathBuf::from("."),
[""] => PathBuf::from("/"),
fixed => PathBuf::from(fixed.join("/")),
};
let rest = components[fixed..]
.iter()
.map(|component| (*component).to_owned())
.collect();
(root, rest)
}
fn collect_directories(directory: &Path, found: &mut BTreeSet<PathBuf>) -> Result<(), String> {
let entries = match std::fs::read_dir(directory) {
Ok(entries) => entries,
Err(_) => return Ok(()),
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
found.insert(path.clone());
collect_directories(&path, found)?;
}
}
Ok(())
}
fn matches_component(name: &str, pattern: &str) -> bool {
let name: Vec<char> = name.chars().collect();
let pattern: Vec<char> = pattern.chars().collect();
let (mut matched, mut consumed) = (0usize, 0usize);
let (mut star, mut resume) = (None, 0usize);
while matched < name.len() {
match pattern.get(consumed) {
Some('*') => {
star = Some(consumed);
resume = matched;
consumed += 1;
}
Some('?') => {
matched += 1;
consumed += 1;
}
Some(&literal) if literal == name[matched] => {
matched += 1;
consumed += 1;
}
_ => match star {
Some(position) => {
consumed = position + 1;
resume += 1;
matched = resume;
}
None => return false,
},
}
}
pattern[consumed..]
.iter()
.all(|&character| character == '*')
}
fn examine(path: PathBuf) -> Entry {
let is_png = path
.extension()
.map(|extension| extension.to_string_lossy().to_lowercase() == PNG_EXTENSION)
.unwrap_or(false);
if !is_png {
return Entry {
path,
verdict: Verdict::Unusable {
reason: "UnsupportedFormat",
dimensions: None,
},
};
}
let verdict = match load_and_validate(&path) {
Ok(image) => {
let dimensions = image.dimensions();
match container_capacity(&image) {
Some(capacity_bytes) => Verdict::Usable {
dimensions,
capacity_bytes,
},
None => Verdict::Unusable {
reason: "InsufficientTexture",
dimensions: Some(dimensions),
},
}
}
Err(error) => {
let (reason, dimensions) = describe(&error);
Verdict::Unusable { reason, dimensions }
}
};
Entry { path, verdict }
}
fn describe(
error: &stenoxide_core::image_io::validate::ValidationError,
) -> (&'static str, Option<(u32, u32)>) {
use stenoxide_core::image_io::validate::ValidationError as Error;
match error {
Error::IoError(_) => ("IoError", None),
Error::JpegDetected => ("JpegDetected", None),
Error::WebpDetected => ("WebpDetected", None),
Error::NotPng => ("NotPng", None),
Error::UnsupportedColorSpace { .. } => ("UnsupportedColorSpace", None),
Error::ImageTooSmall { width, height, .. } => ("ImageTooSmall", Some((*width, *height))),
Error::ImageTooLarge { width, height, .. } => ("ImageTooLarge", Some((*width, *height))),
Error::DecodingError(_) => ("DecodingError", None),
Error::JpegArtifactsDetected { .. } => ("JpegArtifactsDetected", None),
}
}
type Marks = (&'static str, &'static str);
struct Row {
mark: &'static str,
path: String,
size: String,
detail: String,
}
fn listing_marks() -> Marks {
if terminal_renders_unicode() {
("\u{2713}", "\u{2717}")
} else {
("[OK]", "[--]")
}
}
fn render_listing(argument: &str, entries: &[Entry], all: bool) -> String {
render_listing_with(listing_marks(), argument, entries, all)
}
fn render_listing_with(marks: Marks, argument: &str, entries: &[Entry], all: bool) -> String {
let (usable_mark, unusable_mark) = marks;
let mut usable = 0usize;
let mut unusable = 0usize;
let mut rows: Vec<Row> = Vec::new();
let mut rejected: Vec<Row> = Vec::new();
for entry in entries {
let path = entry.path.display().to_string();
match &entry.verdict {
Verdict::Usable {
dimensions: (width, height),
capacity_bytes,
} => {
usable += 1;
rows.push(Row {
mark: usable_mark,
path,
size: format!("{width}x{height}"),
detail: format!("~{:.1} KB", *capacity_bytes as f64 / BYTES_PER_KIB),
});
}
Verdict::Unusable { reason, dimensions } => {
unusable += 1;
if !all {
continue;
}
rejected.push(Row {
mark: unusable_mark,
path,
size: dimensions
.map(|(width, height)| format!("{width}x{height}"))
.unwrap_or_default(),
detail: (*reason).to_owned(),
});
}
}
}
let listed_rejections = !rejected.is_empty();
rows.append(&mut rejected);
let detail_heading = match (usable > 0, listed_rejections) {
(true, true) => CAPACITY_AND_REASON_HEADING,
(true, false) => CAPACITY_HEADING,
(false, _) => REASON_HEADING,
};
let mark_width = usable_mark
.chars()
.count()
.max(unusable_mark.chars().count());
let path_width = column_width(PATH_HEADING, rows.iter().map(|row| row.path.as_str()));
let size_width = column_width(SIZE_HEADING, rows.iter().map(|row| row.size.as_str()));
let mut report = String::new();
let _ = writeln!(report, "Scanning {argument} ...\n");
if !rows.is_empty() {
let _ = writeln!(
report,
" {blank:<mark_width$} {PATH_HEADING:<path_width$} {SIZE_HEADING:<size_width$} {detail_heading}",
blank = ""
);
}
for Row {
mark,
path,
size,
detail,
} in &rows
{
let _ = writeln!(
report,
" {mark:<mark_width$} {path:<path_width$} {size:<size_width$} {detail}"
);
}
let scanned = usable + unusable;
let _ = writeln!(report);
if usable > 0 {
let _ = writeln!(
report,
" * Estimated payload capacity after encryption overhead"
);
}
let _ = writeln!(
report,
" Summary: {usable} valid, {unusable} invalid ({scanned} scanned)"
);
if unusable > 0 && !all {
let _ = writeln!(report, " Run with --all to see why an image was rejected.");
}
if usable > 0 {
let _ = write!(report, "{NEXT_STEP_HINT}");
} else if scanned > 0 {
let _ = write!(report, "{NOTHING_USABLE_HINT}");
}
report
}
fn column_width<'value>(heading: &str, values: impl Iterator<Item = &'value str>) -> usize {
values.fold(heading.chars().count(), |widest, value| {
widest.max(value.chars().count())
})
}
fn render_json(entries: &[Entry]) -> String {
let mut valid = Vec::new();
let mut invalid = Vec::new();
for entry in entries {
let path = escape(&entry.path.display().to_string());
match &entry.verdict {
Verdict::Usable {
dimensions: (width, height),
capacity_bytes,
} => valid.push(format!(
" {{\n \"path\": \"{path}\",\n \"dimensions\": [{width}, {height}],\n \"capacity_kb\": {:.1}\n }}",
*capacity_bytes as f64 / BYTES_PER_KIB
)),
Verdict::Unusable { reason, .. } => invalid.push(format!(
" {{\n \"path\": \"{path}\",\n \"reason\": \"{reason}\"\n }}"
)),
}
}
let render = |records: &[String]| {
if records.is_empty() {
"[]".to_owned()
} else {
format!("[\n{}\n ]", records.join(",\n"))
}
};
format!(
"{{\n \"valid\": {},\n \"invalid\": {},\n \"summary\": {{\n \"scanned\": {},\n \"valid\": {},\n \"invalid\": {}\n }}\n}}\n",
render(&valid),
render(&invalid),
valid.len() + invalid.len(),
valid.len(),
invalid.len()
)
}
fn escape(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
match character {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
control if control < ' ' => {
let _ = write!(escaped, "\\u{:04x}", control as u32);
}
other => escaped.push(other),
}
}
escaped
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
use super::*;
#[test]
fn a_pattern_splits_into_a_root_and_the_parts_still_to_match() {
let split = |pattern: &str| {
let (root, rest) = split_pattern(pattern);
(root.to_string_lossy().into_owned(), rest.join("|"))
};
assert_eq!(split("*.png"), (".".to_owned(), "*.png".to_owned()));
assert_eq!(
split("photos/*.png"),
("photos".to_owned(), "*.png".to_owned())
);
assert_eq!(
split("photos/**/*.png"),
("photos".to_owned(), "**|*.png".to_owned())
);
assert_eq!(
split(r"C:\Users\me\photos\*.png"),
("C:/Users/me/photos".to_owned(), "*.png".to_owned())
);
assert_eq!(
split("/home/me/*.png"),
("/home/me".to_owned(), "*.png".to_owned())
);
assert_eq!(split("/*.png"), ("/".to_owned(), "*.png".to_owned()));
assert_eq!(
split("photos/a.png"),
("photos/a.png".to_owned(), String::new())
);
}
#[test]
fn a_component_with_no_wildcard_matches_only_itself() {
assert!(matches_component("photo.png", "photo.png"));
assert!(!matches_component("photo.png", "photo.jpg"));
assert!(!matches_component("photo.png", "photo"));
}
#[test]
fn a_star_matches_any_run() {
assert!(matches_component("photo.png", "*.png"));
assert!(matches_component("photo.png", "*"));
assert!(matches_component(".png", "*.png"));
assert!(matches_component("a-b-c.png", "a-*-c.png"));
assert!(!matches_component("photo.jpg", "*.png"));
assert!(matches_component("aaa.png.png", "*.png"));
}
#[test]
fn a_question_mark_matches_exactly_one() {
assert!(matches_component("a.png", "?.png"));
assert!(!matches_component("ab.png", "?.png"));
assert!(!matches_component(".png", "?.png"));
}
#[test]
fn a_trailing_star_may_match_nothing() {
assert!(matches_component("photo", "photo*"));
assert!(matches_component("photo", "photo**"));
assert!(!matches_component("photo", "photo?"));
}
#[test]
fn json_strings_are_escaped() {
assert_eq!(escape(r"photos\a.png"), r"photos\\a.png");
assert_eq!(escape("a\"b"), "a\\\"b");
assert_eq!(escape("line\nbreak"), "line\\nbreak");
assert_eq!(escape("bell\u{7}"), "bell\\u0007");
assert_eq!(escape("plain/path.png"), "plain/path.png");
}
#[test]
fn an_empty_scan_renders_an_empty_document() {
let rendered = render_json(&[]);
assert!(rendered.contains("\"valid\": []"), "got: {rendered}");
assert!(rendered.contains("\"invalid\": []"), "got: {rendered}");
assert!(rendered.contains("\"scanned\": 0"), "got: {rendered}");
}
#[test]
fn both_verdicts_reach_the_document() {
let entries = vec![
Entry {
path: PathBuf::from("good.png"),
verdict: Verdict::Usable {
dimensions: (2000, 2400),
capacity_bytes: 12_698,
},
},
Entry {
path: PathBuf::from("bad.png"),
verdict: Verdict::Unusable {
reason: "JpegDetected",
dimensions: None,
},
},
];
let rendered = render_json(&entries);
assert!(
rendered.contains("\"path\": \"good.png\""),
"got: {rendered}"
);
assert!(
rendered.contains("\"dimensions\": [2000, 2400]"),
"got: {rendered}"
);
assert!(
rendered.contains("\"capacity_kb\": 12.4"),
"got: {rendered}"
);
assert!(
rendered.contains("\"reason\": \"JpegDetected\""),
"got: {rendered}"
);
assert!(rendered.contains("\"scanned\": 2"), "got: {rendered}");
assert!(rendered.contains("\"valid\": 1"), "got: {rendered}");
assert!(rendered.contains("\"invalid\": 1"), "got: {rendered}");
}
const MARK_SETS: [Marks; 2] = [("\u{2713}", "\u{2717}"), ("[OK]", "[--]")];
fn table_of(rendered: &str) -> Vec<&str> {
rendered
.lines()
.skip_while(|line| !line.starts_with(" "))
.take_while(|line| !line.trim().is_empty())
.collect()
}
fn offset_of(line: &str, needle: &str) -> usize {
match line.find(needle) {
Some(byte) => line[..byte].chars().count(),
None => panic!("{needle:?} is not in {line:?}"),
}
}
#[test]
fn the_listing_reports_every_entry() {
let entries = vec![
Entry {
path: PathBuf::from("photos/good.png"),
verdict: Verdict::Usable {
dimensions: (3840, 2160),
capacity_bytes: 76_000,
},
},
Entry {
path: PathBuf::from("photos/small.png"),
verdict: Verdict::Unusable {
reason: "ImageTooSmall",
dimensions: Some((400, 400)),
},
},
];
let rendered = render_listing("./photos", &entries, true);
assert!(rendered.contains("Scanning ./photos"), "got: {rendered}");
assert!(rendered.contains("photos/good.png"), "got: {rendered}");
assert!(rendered.contains("3840x2160"), "got: {rendered}");
assert!(rendered.contains("~74.2 KB"), "got: {rendered}");
assert!(rendered.contains("photos/small.png"), "got: {rendered}");
assert!(rendered.contains("400x400"), "got: {rendered}");
assert!(rendered.contains("ImageTooSmall"), "got: {rendered}");
assert!(
rendered.contains("Summary: 1 valid, 1 invalid (2 scanned)"),
"got: {rendered}"
);
}
#[test]
fn one_long_path_does_not_break_the_alignment() {
let entries = vec![
Entry {
path: PathBuf::from("./Alberto_Forest.png"),
verdict: Verdict::Usable {
dimensions: (3264, 2448),
capacity_bytes: 17_000,
},
},
Entry {
path: PathBuf::from("./Alcala_Central_Electrica_de_la_Sierra.png"),
verdict: Verdict::Usable {
dimensions: (2592, 4608),
capacity_bytes: 25_400,
},
},
Entry {
path: PathBuf::from("./Disney.png"),
verdict: Verdict::Usable {
dimensions: (6526, 3679),
capacity_bytes: 51_000,
},
},
];
let values = [
("./Alberto_Forest.png", "3264x2448", "~16.6 KB"),
(
"./Alcala_Central_Electrica_de_la_Sierra.png",
"2592x4608",
"~24.8 KB",
),
("./Disney.png", "6526x3679", "~49.8 KB"),
];
for marks in MARK_SETS {
let rendered = render_listing_with(marks, ".", &entries, false);
let table = table_of(&rendered);
assert_eq!(table.len(), 4, "a heading and three rows: {rendered}");
let columns = (
offset_of(table[0], PATH_HEADING),
offset_of(table[0], SIZE_HEADING),
offset_of(table[0], CAPACITY_HEADING),
);
for (line, (path, size, capacity)) in table[1..].iter().zip(values) {
assert_eq!(
(
offset_of(line, path),
offset_of(line, size),
offset_of(line, capacity)
),
columns,
"{marks:?} left the table ragged: {rendered}"
);
}
}
}
#[test]
fn a_rejected_row_has_the_same_columns_as_a_usable_one() {
let entries = vec![
Entry {
path: PathBuf::from("good.png"),
verdict: Verdict::Usable {
dimensions: (3000, 3000),
capacity_bytes: 22_000,
},
},
Entry {
path: PathBuf::from("small.png"),
verdict: Verdict::Unusable {
reason: "ImageTooSmall",
dimensions: Some((400, 400)),
},
},
Entry {
path: PathBuf::from("not-an-image.png"),
verdict: Verdict::Unusable {
reason: "NotPng",
dimensions: None,
},
},
];
for marks in MARK_SETS {
let rendered = render_listing_with(marks, ".", &entries, true);
let table = table_of(&rendered);
assert_eq!(table.len(), 4, "a heading and three rows: {rendered}");
let path_column = offset_of(table[0], PATH_HEADING);
let size_column = offset_of(table[0], SIZE_HEADING);
let detail_column = offset_of(table[0], CAPACITY_AND_REASON_HEADING);
assert_eq!(
(
offset_of(table[1], "good.png"),
offset_of(table[1], "3000x3000"),
offset_of(table[1], "~21.5 KB")
),
(path_column, size_column, detail_column),
"{marks:?}: {rendered}"
);
assert_eq!(
(
offset_of(table[2], "small.png"),
offset_of(table[2], "400x400"),
offset_of(table[2], "ImageTooSmall")
),
(path_column, size_column, detail_column),
"{marks:?}: {rendered}"
);
assert_eq!(
(
offset_of(table[3], "not-an-image.png"),
offset_of(table[3], "NotPng")
),
(path_column, detail_column),
"{marks:?}: {rendered}"
);
}
}
#[test]
fn the_full_listing_groups_the_usable_containers_first() {
let entries = vec![
Entry {
path: PathBuf::from("a-rejected.png"),
verdict: Verdict::Unusable {
reason: "JpegDetected",
dimensions: None,
},
},
Entry {
path: PathBuf::from("b-usable.png"),
verdict: Verdict::Usable {
dimensions: (2000, 2000),
capacity_bytes: 8_300,
},
},
Entry {
path: PathBuf::from("c-rejected.png"),
verdict: Verdict::Unusable {
reason: "NotPng",
dimensions: None,
},
},
Entry {
path: PathBuf::from("d-usable.png"),
verdict: Verdict::Usable {
dimensions: (2400, 2400),
capacity_bytes: 12_000,
},
},
];
let rendered = render_listing(".", &entries, true);
let order: Vec<&str> = table_of(&rendered)
.into_iter()
.skip(1)
.filter_map(|line| line.split_whitespace().nth(1))
.collect();
assert_eq!(
order,
["b-usable.png", "d-usable.png", "a-rejected.png", "c-rejected.png"],
"got: {rendered}"
);
}
#[test]
fn the_headings_anchor_the_note_about_the_capacity() {
let usable = || Entry {
path: PathBuf::from("good.png"),
verdict: Verdict::Usable {
dimensions: (2000, 2000),
capacity_bytes: 8_300,
},
};
let rejected = || Entry {
path: PathBuf::from("bad.png"),
verdict: Verdict::Unusable {
reason: "NotPng",
dimensions: None,
},
};
let note = "* Estimated payload capacity";
let only_usable = render_listing(".", &[usable()], false);
assert!(only_usable.contains(CAPACITY_HEADING), "got: {only_usable}");
assert!(only_usable.contains(note), "got: {only_usable}");
let both = render_listing(".", &[usable(), rejected()], true);
assert!(both.contains(CAPACITY_AND_REASON_HEADING), "got: {both}");
assert!(both.contains(note), "got: {both}");
let only_rejected = render_listing(".", &[rejected()], true);
assert!(only_rejected.contains(REASON_HEADING), "got: {only_rejected}");
assert!(!only_rejected.contains('*'), "got: {only_rejected}");
assert!(only_rejected.contains(PATH_HEADING), "got: {only_rejected}");
}
#[test]
fn an_empty_table_has_no_headings() {
let rendered = render_listing(".", &[], true);
assert!(!rendered.contains(PATH_HEADING), "got: {rendered}");
assert!(rendered.contains("0 scanned"), "got: {rendered}");
}
#[test]
fn no_line_is_padded_past_its_last_column() {
let entries = vec![
Entry {
path: PathBuf::from("a-very-long-name-for-a-photograph.png"),
verdict: Verdict::Usable {
dimensions: (3000, 3000),
capacity_bytes: 22_000,
},
},
Entry {
path: PathBuf::from("b.png"),
verdict: Verdict::Unusable {
reason: "NotPng",
dimensions: None,
},
},
];
for marks in MARK_SETS {
let rendered = render_listing_with(marks, ".", &entries, true);
for line in rendered.lines() {
assert_eq!(
line.trim_end(),
line,
"{marks:?} padded past the last column: {line:?}"
);
}
}
}
#[test]
fn only_a_scan_that_found_nothing_offers_to_generate_a_container() {
let refused = vec![Entry {
path: PathBuf::from("a.png"),
verdict: Verdict::Unusable {
reason: "JpegDetected",
dimensions: None,
},
}];
let listing = render_listing(".", &refused, true);
assert!(listing.contains("stenoxide generate"), "got: {listing}");
assert!(
listing.contains("does not hide that it was generated"),
"the offer must state what the mode does not do: {listing}"
);
let mut mixed = refused;
mixed.push(Entry {
path: PathBuf::from("b.png"),
verdict: Verdict::Usable {
dimensions: (2000, 2000),
capacity_bytes: 8_300,
},
});
assert!(!render_listing(".", &mixed, true).contains("stenoxide generate"));
assert!(!render_listing(".", &[], true).contains("stenoxide generate"));
}
#[test]
fn a_scan_that_found_something_names_the_next_command() {
let entries = vec![Entry {
path: PathBuf::from("holiday.png"),
verdict: Verdict::Usable {
dimensions: (2000, 2000),
capacity_bytes: 8_300,
},
}];
let listing = render_listing(".", &entries, false);
assert!(listing.contains("stenoxide embed"), "got: {listing}");
assert!(
!listing.contains("--input holiday.png"),
"the line must not recommend one of the listed files: {listing}"
);
}
#[test]
fn the_two_pointers_never_appear_together() {
let usable = || Entry {
path: PathBuf::from("good.png"),
verdict: Verdict::Usable {
dimensions: (2000, 2000),
capacity_bytes: 8_300,
},
};
let rejected = || Entry {
path: PathBuf::from("bad.png"),
verdict: Verdict::Unusable {
reason: "JpegDetected",
dimensions: None,
},
};
let found = render_listing(".", &[usable(), rejected()], true);
assert!(found.contains("stenoxide embed"), "got: {found}");
assert!(!found.contains("stenoxide generate"), "got: {found}");
let empty_handed = render_listing(".", &[rejected()], true);
assert!(
empty_handed.contains("stenoxide generate"),
"got: {empty_handed}"
);
assert!(
!empty_handed.contains("stenoxide embed"),
"got: {empty_handed}"
);
let nothing = render_listing(".", &[], true);
assert!(!nothing.contains("stenoxide embed"), "got: {nothing}");
assert!(!nothing.contains("stenoxide generate"), "got: {nothing}");
}
#[test]
fn a_short_listing_says_how_to_see_the_rest() {
let entries = vec![Entry {
path: PathBuf::from("a.png"),
verdict: Verdict::Unusable {
reason: "JpegDetected",
dimensions: None,
},
}];
assert!(render_listing(".", &entries, false).contains("--all"));
assert!(!render_listing(".", &entries, true).contains("--all"));
}
}