use std::fs;
use std::path::Path;
use std::process::Output;
use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::PredicateBooleanExt;
fn bmp_bytes(width: u32, height: u32) -> Vec<u8> {
let row_stride = (width * 3).div_ceil(4) * 4;
let pixel_array_size = row_stride * height;
let file_size = 54 + pixel_array_size;
let mut bytes = Vec::with_capacity(file_size as usize);
bytes.extend_from_slice(b"BM");
bytes.extend_from_slice(&file_size.to_le_bytes());
bytes.extend_from_slice(&[0, 0, 0, 0]);
bytes.extend_from_slice(&54u32.to_le_bytes());
bytes.extend_from_slice(&40u32.to_le_bytes());
bytes.extend_from_slice(&(width as i32).to_le_bytes());
bytes.extend_from_slice(&(height as i32).to_le_bytes());
bytes.extend_from_slice(&1u16.to_le_bytes());
bytes.extend_from_slice(&24u16.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&pixel_array_size.to_le_bytes());
bytes.extend_from_slice(&2835u32.to_le_bytes());
bytes.extend_from_slice(&2835u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.resize(file_size as usize, 0);
bytes
}
fn write_bmp(path: &Path, width: u32, height: u32) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent dir");
}
fs::write(path, bmp_bytes(width, height)).expect("write bmp file");
}
fn create_sample_yolo_dataset(root: &Path) {
fs::create_dir_all(root.join("images/train")).expect("create images dir");
fs::create_dir_all(root.join("labels/train")).expect("create labels dir");
write_bmp(&root.join("images/train/img1.bmp"), 16, 8);
write_bmp(&root.join("images/train/img2.bmp"), 10, 10);
fs::write(root.join("data.yaml"), "names:\n - person\n - car\n").expect("write data yaml");
fs::write(
root.join("labels/train/img1.txt"),
"0 0.5 0.5 0.5 0.5\n1 0.2 0.3 0.2 0.2\n",
)
.expect("write labels for img1");
fs::write(root.join("labels/train/img2.txt"), "").expect("write empty label file");
}
fn create_sample_voc_dataset(root: &Path) {
fs::create_dir_all(root.join("Annotations")).expect("create annotations dir");
fs::create_dir_all(root.join("JPEGImages")).expect("create images dir");
let xml_1 = r#"<?xml version="1.0" encoding="utf-8"?>
<annotation>
<filename>img1.jpg</filename>
<size>
<width>100</width>
<height>80</height>
<depth>3</depth>
</size>
<object>
<name>person</name>
<bndbox>
<xmin>10</xmin>
<ymin>20</ymin>
<xmax>50</xmax>
<ymax>70</ymax>
</bndbox>
</object>
</annotation>
"#;
let xml_2 = r#"<?xml version="1.0" encoding="utf-8"?>
<annotation>
<filename>img2.jpg</filename>
<size>
<width>60</width>
<height>40</height>
<depth>3</depth>
</size>
</annotation>
"#;
fs::write(root.join("Annotations/img1.xml"), xml_1).expect("write img1 xml");
fs::write(root.join("Annotations/img2.xml"), xml_2).expect("write img2 xml");
}
fn create_sample_cvat_export(root: &Path) {
fs::create_dir_all(root).expect("create cvat root");
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<annotations>
<version>1.1</version>
<meta>
<task>
<labels>
<label><name>person</name><type>bbox</type></label>
</labels>
</task>
</meta>
<image id="0" name="img1.jpg" width="100" height="80">
<box label="person" occluded="0" xtl="10" ytl="20" xbr="50" ybr="70" z_order="0" source="manual"/>
</image>
</annotations>
"#;
fs::write(root.join("annotations.xml"), xml).expect("write cvat annotations.xml");
}
fn create_sample_hf_dataset(root: &Path, xyxy: bool) {
fs::create_dir_all(root).expect("create hf root");
write_bmp(&root.join("img1.bmp"), 100, 80);
write_bmp(&root.join("img2.bmp"), 50, 40);
let bbox_row_1 = if xyxy {
"[[10,20,50,70]]"
} else {
"[[10,20,40,50]]"
};
let bbox_row_2 = if xyxy {
"[[5,5,20,20]]"
} else {
"[[5,5,15,15]]"
};
let metadata = format!(
"{{\"file_name\":\"img1.bmp\",\"width\":100,\"height\":80,\"objects\":{{\"bbox\":{},\"categories\":[\"person\"]}}}}\n{{\"file_name\":\"img2.bmp\",\"width\":50,\"height\":40,\"objects\":{{\"bbox\":{},\"categories\":[\"car\"]}}}}\n",
bbox_row_1, bbox_row_2
);
fs::write(root.join("metadata.jsonl"), metadata).expect("write hf metadata");
}
fn stdout_json(output: &Output) -> (String, serde_json::Value) {
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let parsed = serde_json::from_str(&stdout).expect("stdout should be valid JSON");
(stdout, parsed)
}
fn assert_compact_json(stdout: &str) {
assert!(
stdout.ends_with('\n'),
"JSON stdout should end with a newline"
);
assert_eq!(
stdout.lines().count(),
1,
"captured JSON should be a single line"
);
assert!(
!stdout.starts_with("{\n") && !stdout.starts_with("[\n"),
"captured JSON should be compact rather than pretty-printed"
);
}
#[test]
fn runs() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.assert().success();
}
#[test]
fn outputs_tool_name() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.arg("-V");
let expected = format!("panlabel {}\n", env!("CARGO_PKG_VERSION"));
cmd.assert().success().stdout(expected);
}
#[test]
fn validate_valid_dataset_succeeds() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["validate", "tests/fixtures/sample_valid.ir.json"]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_invalid_dataset_fails() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["validate", "tests/fixtures/sample_invalid.ir.json"]);
cmd.assert()
.failure()
.stdout(predicates::str::contains("error(s)"));
}
#[test]
fn validate_reports_duplicate_ids() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["validate", "tests/fixtures/sample_invalid.ir.json"]);
cmd.assert()
.failure()
.stdout(predicates::str::contains("DuplicateImageId"));
}
#[test]
fn validate_reports_missing_refs() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["validate", "tests/fixtures/sample_invalid.ir.json"]);
cmd.assert()
.failure()
.stdout(predicates::str::contains("MissingImageRef"))
.stdout(predicates::str::contains("MissingCategoryRef"));
}
#[test]
fn validate_json_output_format() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_valid.ir.json",
"--output-format",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert_eq!(parsed["error_count"], 0);
assert_eq!(parsed["warning_count"], 0);
}
#[test]
fn validate_nonexistent_file_fails() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["validate", "nonexistent_file.json"]);
cmd.assert().failure();
}
#[test]
fn validate_coco_valid_dataset_succeeds() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_valid.coco.json",
"--format",
"coco",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_coco_invalid_dataset_fails() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_invalid.coco.json",
"--format",
"coco",
]);
cmd.assert()
.failure()
.stdout(predicates::str::contains("error(s)"));
}
#[test]
fn validate_coco_reports_duplicate_ids() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_invalid.coco.json",
"--format",
"coco",
]);
cmd.assert()
.failure()
.stdout(predicates::str::contains("DuplicateImageId"));
}
#[test]
fn validate_coco_reports_missing_refs() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_invalid.coco.json",
"--format",
"coco",
]);
cmd.assert()
.failure()
.stdout(predicates::str::contains("MissingImageRef"))
.stdout(predicates::str::contains("MissingCategoryRef"));
}
#[test]
fn validate_coco_json_alias_works() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_valid.coco.json",
"--format",
"coco-json",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_tfod_valid_dataset_succeeds() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_valid.tfod.csv",
"--format",
"tfod",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_tfod_csv_alias_works() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_valid.tfod.csv",
"--format",
"tfod-csv",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_yolo_dataset_succeeds() {
let temp = tempfile::tempdir().expect("create temp dir");
create_sample_yolo_dataset(temp.path());
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
temp.path().to_str().unwrap(),
"--format",
"yolo",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_yolo_alias_works() {
let temp = tempfile::tempdir().expect("create temp dir");
create_sample_yolo_dataset(temp.path());
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
temp.path().to_str().unwrap(),
"--format",
"yolov8",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_voc_dataset_succeeds() {
let temp = tempfile::tempdir().expect("create temp dir");
create_sample_voc_dataset(temp.path());
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["validate", temp.path().to_str().unwrap(), "--format", "voc"]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_voc_alias_works() {
let temp = tempfile::tempdir().expect("create temp dir");
create_sample_voc_dataset(temp.path());
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
temp.path().to_str().unwrap(),
"--format",
"pascal-voc",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_label_studio_dataset_succeeds() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_valid.label_studio.json",
"--format",
"label-studio",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_label_studio_alias_works() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_valid.label_studio.json",
"--format",
"ls",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_cvat_dataset_succeeds() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_valid.cvat.xml",
"--format",
"cvat",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_cvat_alias_works() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_valid.cvat.xml",
"--format",
"cvat-xml",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_hf_dataset_succeeds() {
let temp = tempfile::tempdir().expect("create temp dir");
create_sample_hf_dataset(temp.path(), false);
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["validate", temp.path().to_str().unwrap(), "--format", "hf"]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
#[ignore] fn validate_tfod_large_dataset_succeeds() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"assets/tfod_annotations.csv",
"--format",
"tfod",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn validate_unsupported_format_fails() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_valid.ir.json",
"--format",
"not-a-format",
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("invalid value 'not-a-format'"))
.stderr(predicates::str::contains("possible values"));
}
#[test]
fn validate_invalid_output_format_fails() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"validate",
"tests/fixtures/sample_valid.ir.json",
"--output",
"yaml",
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("invalid value 'yaml'"))
.stderr(predicates::str::contains("possible values"));
}
#[test]
fn convert_coco_to_ir_json_succeeds() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_convert_coco_to_ir.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"ir-json",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Converted"))
.stdout(predicates::str::contains("2 images"))
.stdout(predicates::str::contains("3 annotations"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_ir_json_to_coco_succeeds() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_convert_ir_to_coco.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"ir-json",
"-t",
"coco",
"-i",
"tests/fixtures/sample_valid.ir.json",
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Converted"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_tfod_to_coco_succeeds() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_convert_tfod_to_coco.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"tfod",
"-t",
"coco",
"-i",
"tests/fixtures/sample_valid.tfod.csv",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Converted"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_coco_to_tfod_fails_without_allow_lossy() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_convert_coco_to_tfod.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"tfod",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("Lossy conversion"))
.stderr(predicates::str::contains("--allow-lossy"))
.stdout(predicates::str::contains("[drop_dataset_info]"))
.stdout(predicates::str::contains("Warnings"))
.stdout(predicates::str::contains("Notes"));
}
#[test]
fn convert_coco_to_tfod_succeeds_with_allow_lossy() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_convert_coco_to_tfod_lossy.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"tfod",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Converted"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn blocked_convert_json_emits_valid_json_to_stdout() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_blocked_json.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"tfod",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--output-format",
"json",
]);
let output = cmd.output().expect("run command");
assert!(!output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert_eq!(parsed["from"], "coco");
assert_eq!(parsed["to"], "tfod");
let issues = parsed["issues"]
.as_array()
.expect("issues should be an array");
assert!(issues.iter().any(|i| i["code"] == "drop_dataset_info"));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Lossy conversion"));
assert!(stderr.contains("--allow-lossy"));
}
#[test]
fn blocked_convert_text_shows_stable_codes() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_blocked_text_codes.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"tfod",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stdout(predicates::str::contains("[drop_dataset_info]"))
.stdout(predicates::str::contains("[drop_licenses]"))
.stdout(predicates::str::contains("[tfod_writer_row_order]"))
.stdout(predicates::str::contains("images"))
.stdout(predicates::str::contains("annotations"));
}
#[test]
fn success_convert_text_shows_stable_codes() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_success_text_codes.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"tfod",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Converted"))
.stdout(predicates::str::contains("[drop_dataset_info]"))
.stdout(predicates::str::contains("[tfod_writer_row_order]"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_dry_run_text_does_not_overwrite_existing_output() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("existing.ir.json");
fs::write(&output_path, "keep me").expect("seed output");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"ir-json",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--dry-run",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dry run: would convert"))
.stdout(predicates::str::contains("images"))
.stdout(predicates::str::contains("annotations"));
assert_eq!(
fs::read_to_string(&output_path).expect("read output"),
"keep me"
);
}
#[test]
fn convert_dry_run_json_emits_compact_report_only_and_skips_write() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("dry_run.ir.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"ir-json",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--dry-run",
"--output-format",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
assert!(!output_path.exists());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert_eq!(parsed["from"], "coco");
assert_eq!(parsed["to"], "ir-json");
assert!(parsed.get("issues").is_some());
assert!(!stdout.contains("Converted"));
assert!(!stdout.contains("Dry run"));
}
#[test]
fn convert_dry_run_blocked_lossy_still_errors_without_writing() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("dry_run.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"tfod",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--dry-run",
]);
cmd.assert()
.failure()
.stdout(predicates::str::contains("[drop_dataset_info]"))
.stderr(predicates::str::contains("Lossy conversion"));
assert!(!output_path.exists());
}
#[test]
fn convert_dry_run_directory_target_does_not_create_output_dir() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("dry_run_yolo");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"yolo",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
"--dry-run",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dry run: would convert"));
assert!(!output_path.exists());
}
#[test]
fn convert_dry_run_blocked_json_still_emits_report_without_writing() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("dry_run.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"tfod",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--dry-run",
"--output-format",
"json",
]);
let output = cmd.output().expect("run command");
assert!(!output.status.success());
assert!(!output_path.exists());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert_eq!(parsed["from"], "coco");
assert_eq!(parsed["to"], "tfod");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Lossy conversion"));
}
#[test]
fn convert_ir_json_to_yolo_fails_without_allow_lossy() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("yolo_out");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"ir-json",
"-t",
"yolo",
"-i",
"tests/fixtures/sample_valid.ir.json",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("Lossy conversion"))
.stderr(predicates::str::contains("--allow-lossy"));
}
#[test]
fn convert_yolo_to_coco_succeeds() {
let temp = tempfile::tempdir().expect("create temp dir");
let yolo_dir = temp.path().join("sample_yolo");
create_sample_yolo_dataset(&yolo_dir);
let output_path = temp.path().join("yolo_to_coco.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"yolo",
"-t",
"coco",
"-i",
yolo_dir.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Converted"));
}
#[test]
fn convert_voc_to_coco_succeeds() {
let temp = tempfile::tempdir().expect("create temp dir");
let voc_dir = temp.path().join("sample_voc");
create_sample_voc_dataset(&voc_dir);
let output_path = temp.path().join("voc_to_coco.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"voc",
"-t",
"coco",
"-i",
voc_dir.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Converted"));
}
#[test]
fn convert_ir_json_to_voc_fails_without_allow_lossy() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("voc_out");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"ir-json",
"-t",
"voc",
"-i",
"tests/fixtures/sample_valid.ir.json",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("Lossy conversion"))
.stderr(predicates::str::contains("--allow-lossy"));
}
#[test]
fn convert_ir_json_to_voc_succeeds_with_allow_lossy() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("voc_out");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"ir-json",
"-t",
"voc",
"-i",
"tests/fixtures/sample_valid.ir.json",
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Converted"));
}
#[test]
fn convert_ir_json_to_cvat_fails_without_allow_lossy() {
let temp = tempfile::tempdir().expect("create temp dir");
let input_path = temp.path().join("in.ir.json");
let output_path = temp.path().join("out.cvat.xml");
let ir = r#"{
"info": {"name": "needs-lossy-opt-in"},
"images": [{"id": 1, "file_name": "img.jpg", "width": 100, "height": 80}],
"categories": [{"id": 1, "name": "person"}],
"annotations": [{"id": 1, "image_id": 1, "category_id": 1, "bbox": {"xmin": 10.0, "ymin": 20.0, "xmax": 50.0, "ymax": 70.0}}]
}"#;
fs::write(&input_path, ir).expect("write input");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"ir-json",
"-t",
"cvat",
"-i",
input_path.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("Lossy conversion"))
.stderr(predicates::str::contains("--allow-lossy"));
}
#[test]
fn convert_ir_json_to_cvat_succeeds_with_allow_lossy_and_report_has_policy_note() {
let temp = tempfile::tempdir().expect("create temp dir");
let input_path = temp.path().join("in.ir.json");
let output_path = temp.path().join("out.cvat.xml");
let ir = r#"{
"info": {"name": "needs-lossy-opt-in"},
"images": [{"id": 1, "file_name": "img.jpg", "width": 100, "height": 80}],
"categories": [{"id": 1, "name": "person"}],
"annotations": [{"id": 1, "image_id": 1, "category_id": 1, "bbox": {"xmin": 10.0, "ymin": 20.0, "xmax": 50.0, "ymax": 70.0}}]
}"#;
fs::write(&input_path, ir).expect("write input");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"ir-json",
"-t",
"cvat",
"-i",
input_path.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
"--report",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert_eq!(parsed["to"], "cvat");
assert!(parsed["issues"].is_array());
assert!(stdout.contains("cvat_writer_meta_defaults"));
}
#[test]
fn convert_ir_json_to_label_studio_fails_without_allow_lossy() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("label_studio_out.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"ir-json",
"-t",
"label-studio",
"-i",
"tests/fixtures/sample_valid.ir.json",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("Lossy conversion"))
.stderr(predicates::str::contains("--allow-lossy"));
}
#[test]
fn convert_from_yolo_alias_works() {
let temp = tempfile::tempdir().expect("create temp dir");
let yolo_dir = temp.path().join("sample_yolo");
create_sample_yolo_dataset(&yolo_dir);
let output_path = temp.path().join("alias_out.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"ultralytics",
"-t",
"coco",
"-i",
yolo_dir.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Converted"));
}
#[test]
fn convert_hf_to_ir_json_succeeds() {
let temp = tempfile::tempdir().expect("create temp dir");
let hf_dir = temp.path().join("sample_hf");
create_sample_hf_dataset(&hf_dir, false);
let output_path = temp.path().join("hf_to_ir.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"hf",
"-t",
"ir-json",
"-i",
hf_dir.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(hf)"));
}
#[test]
fn convert_hf_xyxy_bbox_flag_succeeds() {
let temp = tempfile::tempdir().expect("create temp dir");
let hf_dir = temp.path().join("sample_hf_xyxy");
create_sample_hf_dataset(&hf_dir, true);
let output_path = temp.path().join("hf_xyxy_to_ir.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"hf",
"-t",
"ir-json",
"-i",
hf_dir.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
"--hf-bbox-format",
"xyxy",
]);
cmd.assert().success();
}
#[test]
fn convert_hf_specific_flags_fail_for_non_hf_formats() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("bad_flags.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"ir-json",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--hf-objects-column",
"objects",
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("HF-specific flags"));
}
#[test]
fn convert_hf_repo_requires_hf_remote_feature() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("hf_repo.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"hf",
"-t",
"ir-json",
"--hf-repo",
"org/dataset",
"-o",
output_path.to_str().unwrap(),
]);
#[cfg(feature = "hf-remote")]
{
cmd.assert().failure();
}
#[cfg(not(feature = "hf-remote"))]
{
cmd.assert()
.failure()
.stderr(predicates::str::contains("hf-remote"));
}
}
#[test]
fn convert_invalid_input_fails_validation() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_convert_invalid.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"ir-json",
"-i",
"tests/fixtures/sample_invalid.coco.json",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("Validation failed"));
}
#[test]
fn convert_invalid_input_succeeds_with_no_validate() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_convert_no_validate.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"ir-json",
"-i",
"tests/fixtures/sample_invalid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--no-validate",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Converted"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_format_aliases_work() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_convert_aliases.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco-json",
"-t",
"tfod-csv",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Converted"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_nonexistent_file_fails() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"ir-json",
"-i",
"nonexistent_file.json",
"-o",
"/tmp/output.json",
]);
cmd.assert().failure();
}
#[test]
fn convert_report_json_output_format() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_convert_report_json.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"ir-json",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--output-format",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert_eq!(parsed["from"], "coco");
assert_eq!(parsed["to"], "ir-json");
assert!(parsed.get("input").is_some());
assert!(parsed.get("output").is_some());
assert!(parsed.get("issues").is_some());
assert!(!stdout.contains("Converted"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_report_json_includes_lossy_warnings() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_convert_report_lossy.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"tfod",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
"--output-format",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
let issues = parsed["issues"]
.as_array()
.expect("issues should be an array");
assert!(issues.iter().any(|issue| issue["severity"] == "warning"));
assert!(issues.iter().all(|issue| issue.get("code").is_some()));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_report_text_shows_counts() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_convert_report_text.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"ir-json",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Converted"))
.stdout(predicates::str::contains("images"))
.stdout(predicates::str::contains("categories"))
.stdout(predicates::str::contains("annotations"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_tfod_to_coco_shows_policy_notes() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_convert_policy_notes.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"tfod",
"-t",
"coco",
"-i",
"tests/fixtures/sample_valid.tfod.csv",
"-o",
output_path.to_str().unwrap(),
"--report",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
let issues = parsed["issues"].as_array().expect("issues array");
assert!(issues.iter().any(|issue| issue["severity"] == "info"));
assert!(stdout.contains("tfod_reader_id_assignment"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_to_yolo_report_includes_policy_notes() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("report_yolo");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"yolo",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
"--report",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
let issues = parsed["issues"].as_array().expect("issues array");
assert!(issues.iter().any(|issue| issue["severity"] == "info"));
assert!(stdout.contains("yolo_writer_float_precision"));
}
#[test]
fn convert_to_voc_report_includes_policy_notes() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("report_voc");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"coco",
"-t",
"voc",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
"--report",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
let issues = parsed["issues"].as_array().expect("issues array");
assert!(issues.iter().any(|issue| issue["severity"] == "info"));
assert!(stdout.contains("voc_writer_bool_normalization"));
}
#[test]
fn convert_to_label_studio_report_includes_policy_notes() {
let temp = tempfile::tempdir().expect("create temp dir");
let output_path = temp.path().join("report_label_studio.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"-f",
"ir-json",
"-t",
"label-studio",
"-i",
"tests/fixtures/sample_valid.ir.json",
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
"--report",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
let issues = parsed["issues"].as_array().expect("issues array");
assert!(issues.iter().any(|issue| issue["severity"] == "info"));
assert!(stdout.contains("label_studio_writer_from_to_defaults"));
}
#[test]
fn stats_coco_dataset_succeeds() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"stats",
"--format",
"coco",
"tests/fixtures/sample_valid.coco.json",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dataset Stats Report"))
.stdout(predicates::str::contains("Summary"))
.stdout(predicates::str::contains("Labels"))
.stdout(predicates::str::contains("Bounding Boxes"));
}
#[test]
fn stats_ir_json_dataset_succeeds() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"stats",
"--format",
"ir-json",
"tests/fixtures/sample_valid.ir.json",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dataset Stats Report"))
.stdout(predicates::str::contains("Images"));
}
#[test]
fn stats_tfod_dataset_succeeds() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"stats",
"--format",
"tfod",
"tests/fixtures/sample_valid.tfod.csv",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dataset Stats Report"))
.stdout(predicates::str::contains("Annotations"));
}
#[test]
fn stats_voc_dataset_succeeds() {
let temp = tempfile::tempdir().expect("create temp dir");
create_sample_voc_dataset(temp.path());
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["stats", "--format", "voc", temp.path().to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dataset Stats Report"))
.stdout(predicates::str::contains("Images"));
}
#[test]
fn stats_label_studio_dataset_succeeds() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"stats",
"--format",
"label-studio",
"tests/fixtures/sample_valid.label_studio.json",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dataset Stats Report"))
.stdout(predicates::str::contains("Images"));
}
#[test]
fn stats_shows_label_histogram() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"stats",
"--format",
"coco",
"tests/fixtures/sample_valid.coco.json",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("person").or(predicates::str::contains("Labels")));
}
#[test]
fn stats_top_flag_limits_labels() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"stats",
"--format",
"coco",
"tests/fixtures/sample_valid.coco.json",
"--top",
"2",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Labels"));
}
#[test]
fn stats_nonexistent_file_fails() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["stats", "--format", "coco", "nonexistent_file.json"]);
cmd.assert().failure();
}
#[test]
fn stats_auto_detects_coco_when_format_omitted() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["stats", "tests/fixtures/sample_valid.coco.json"]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dataset Stats Report"));
}
#[test]
fn stats_falls_back_to_ir_json_when_detection_fails_for_json_file() {
let temp = tempfile::tempdir().expect("tempdir");
let p = temp.path().join("empty.ir.json");
fs::write(
&p,
r#"{"info":{},"images":[],"categories":[],"annotations":[]}"#,
)
.expect("write");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["stats", p.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dataset Stats Report"));
}
#[test]
fn stats_directory_detection_errors_without_fallback() {
let temp = tempfile::tempdir().expect("tempdir");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["stats", temp.path().to_str().unwrap()]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("unrecognized directory layout"));
}
#[test]
fn stats_json_output_contains_expected_keys() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"stats",
"--output-format",
"json",
"tests/fixtures/sample_valid.coco.json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert!(parsed.get("summary").is_some());
assert!(parsed.get("labels").is_some());
assert!(parsed.get("bboxes").is_some());
assert!(parsed.get("cooccurrence_top_pairs").is_some());
}
#[test]
fn stats_text_output_is_plain_when_stdout_is_captured() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["stats", "tests/fixtures/sample_valid.coco.json"]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Dataset Stats Report"));
assert!(stdout.contains("Summary\n-------"));
assert!(stdout.contains("Bounding Boxes"));
assert!(!stdout.contains('📊'));
assert!(!stdout.contains('â•'));
assert!(!stdout.contains('â–ˆ'));
assert!(!stdout.contains('…'));
}
#[test]
fn stats_html_output_contains_expected_markers() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"stats",
"--output",
"html",
"tests/fixtures/sample_valid.coco.json",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("<title>panlabel stats</title>"))
.stdout(predicates::str::contains("id=\"stats-data\""))
.stdout(predicates::str::contains("id=\"labels-chart\""));
}
#[test]
fn diff_identical_ir_json_has_no_changes() {
let temp = tempfile::tempdir().expect("tempdir");
let a = temp.path().join("a.ir.json");
let b = temp.path().join("b.ir.json");
let content = fs::read_to_string("tests/fixtures/sample_valid.ir.json").expect("read fixture");
fs::write(&a, &content).expect("write a");
fs::write(&b, &content).expect("write b");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"diff",
a.to_str().unwrap(),
b.to_str().unwrap(),
"--format-a",
"ir-json",
"--format-b",
"ir-json",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dataset Diff"))
.stdout(predicates::str::contains("0 only in A, 0 only in B"));
}
#[test]
fn diff_id_mode_bbox_change_within_epsilon_is_not_modified() {
let temp = tempfile::tempdir().expect("tempdir");
let a = temp.path().join("a.ir.json");
let b = temp.path().join("b.ir.json");
let a_json = r#"{"info":{},"images":[{"id":1,"file_name":"img.jpg","width":100,"height":100}],"categories":[{"id":1,"name":"cat"}],"annotations":[{"id":1,"image_id":1,"category_id":1,"bbox":{"xmin":10.0,"ymin":10.0,"xmax":20.0,"ymax":20.0}}]}"#;
let b_json = r#"{"info":{},"images":[{"id":1,"file_name":"img.jpg","width":100,"height":100}],"categories":[{"id":1,"name":"cat"}],"annotations":[{"id":1,"image_id":1,"category_id":1,"bbox":{"xmin":10.0000005,"ymin":10.0,"xmax":20.0,"ymax":20.0}}]}"#;
fs::write(&a, a_json).expect("write a");
fs::write(&b, b_json).expect("write b");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"diff",
a.to_str().unwrap(),
b.to_str().unwrap(),
"--format-a",
"ir-json",
"--format-b",
"ir-json",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("modified (0)"));
}
#[test]
fn diff_id_mode_bbox_change_beyond_epsilon_is_modified() {
let temp = tempfile::tempdir().expect("tempdir");
let a = temp.path().join("a.ir.json");
let b = temp.path().join("b.ir.json");
let a_json = r#"{"info":{},"images":[{"id":1,"file_name":"img.jpg","width":100,"height":100}],"categories":[{"id":1,"name":"cat"}],"annotations":[{"id":1,"image_id":1,"category_id":1,"bbox":{"xmin":10.0,"ymin":10.0,"xmax":20.0,"ymax":20.0}}]}"#;
let b_json = r#"{"info":{},"images":[{"id":1,"file_name":"img.jpg","width":100,"height":100}],"categories":[{"id":1,"name":"cat"}],"annotations":[{"id":1,"image_id":1,"category_id":1,"bbox":{"xmin":10.0001,"ymin":10.0,"xmax":20.0,"ymax":20.0}}]}"#;
fs::write(&a, a_json).expect("write a");
fs::write(&b, b_json).expect("write b");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"diff",
a.to_str().unwrap(),
b.to_str().unwrap(),
"--format-a",
"ir-json",
"--format-b",
"ir-json",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("modified (1)"));
}
#[test]
fn diff_detail_prints_detail_sections() {
let temp = tempfile::tempdir().expect("tempdir");
let a = temp.path().join("a.ir.json");
let b = temp.path().join("b.ir.json");
let a_json = r#"{"info":{},"images":[{"id":1,"file_name":"a.jpg","width":10,"height":10}],"categories":[{"id":1,"name":"cat"}],"annotations":[]}"#;
let b_json = r#"{"info":{},"images":[{"id":1,"file_name":"b.jpg","width":10,"height":10}],"categories":[{"id":1,"name":"cat"}],"annotations":[]}"#;
fs::write(&a, a_json).expect("write a");
fs::write(&b, b_json).expect("write b");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"diff",
a.to_str().unwrap(),
b.to_str().unwrap(),
"--format-a",
"ir-json",
"--format-b",
"ir-json",
"--detail",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Images only in A:"))
.stdout(predicates::str::contains("Images only in B:"));
}
#[test]
fn diff_json_output_contains_expected_keys() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"diff",
"tests/fixtures/sample_valid.ir.json",
"tests/fixtures/sample_valid.ir.json",
"--format-a",
"ir-json",
"--format-b",
"ir-json",
"--output-format",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert!(parsed.get("images").is_some());
assert!(parsed.get("categories").is_some());
assert!(parsed.get("annotations").is_some());
}
#[test]
fn diff_iou_mode_matches_different_ids() {
let temp = tempfile::tempdir().expect("tempdir");
let a = temp.path().join("a.ir.json");
let b = temp.path().join("b.ir.json");
let a_json = r#"{"info":{},"images":[{"id":1,"file_name":"img.jpg","width":100,"height":100}],"categories":[{"id":1,"name":"cat"}],"annotations":[{"id":1,"image_id":1,"category_id":1,"bbox":{"xmin":10.0,"ymin":10.0,"xmax":20.0,"ymax":20.0}}]}"#;
let b_json = r#"{"info":{},"images":[{"id":1,"file_name":"img.jpg","width":100,"height":100}],"categories":[{"id":1,"name":"cat"}],"annotations":[{"id":999,"image_id":1,"category_id":1,"bbox":{"xmin":10.0,"ymin":10.0,"xmax":20.0,"ymax":20.0}}]}"#;
fs::write(&a, a_json).expect("write a");
fs::write(&b, b_json).expect("write b");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"diff",
a.to_str().unwrap(),
b.to_str().unwrap(),
"--format-a",
"ir-json",
"--format-b",
"ir-json",
"--match-by",
"iou",
"--iou-threshold",
"0.5",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Annotations:"))
.stdout(predicates::str::contains("modified (0)"));
}
#[test]
fn sample_n_writes_output_and_validates() {
let temp = tempfile::tempdir().expect("tempdir");
let out = temp.path().join("out.ir.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"ir-json",
"-n",
"1",
]);
cmd.assert().success();
let mut validate = cargo_bin_cmd!("panlabel");
validate.args(["validate", out.to_str().unwrap(), "--format", "ir-json"]);
validate
.assert()
.success()
.stdout(predicates::str::contains("Validation passed"));
}
#[test]
fn sample_fraction_writes_output() {
let temp = tempfile::tempdir().expect("tempdir");
let out = temp.path().join("out.ir.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"ir-json",
"--fraction",
"0.5",
"--seed",
"42",
]);
cmd.assert().success();
assert!(out.is_file());
}
#[test]
fn sample_rejects_n_and_fraction_together() {
let temp = tempfile::tempdir().expect("tempdir");
let out = temp.path().join("out.ir.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"ir-json",
"-n",
"1",
"--fraction",
"0.5",
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("Invalid sample parameters"));
}
#[test]
fn sample_seed_is_deterministic() {
let temp = tempfile::tempdir().expect("tempdir");
let out1 = temp.path().join("out1.ir.json");
let out2 = temp.path().join("out2.ir.json");
for out in [&out1, &out2] {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"ir-json",
"-n",
"1",
"--seed",
"123",
]);
cmd.assert().success();
}
let b1 = fs::read(&out1).expect("read out1");
let b2 = fs::read(&out2).expect("read out2");
assert_eq!(b1, b2);
}
#[test]
fn sample_category_mode_annotations_keeps_all_categories() {
let temp = tempfile::tempdir().expect("tempdir");
let input = temp.path().join("in.ir.json");
let out = temp.path().join("out.ir.json");
let ds = r#"{
"info": {},
"images": [{"id":1,"file_name":"img.jpg","width":10,"height":10}],
"categories": [{"id":1,"name":"person"},{"id":2,"name":"dog"}],
"annotations": [
{"id":1,"image_id":1,"category_id":1,"bbox":{"xmin":1.0,"ymin":1.0,"xmax":2.0,"ymax":2.0}},
{"id":2,"image_id":1,"category_id":2,"bbox":{"xmin":3.0,"ymin":3.0,"xmax":4.0,"ymax":4.0}}
]
}"#;
fs::write(&input, ds).expect("write input");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
input.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--from",
"ir-json",
"--to",
"ir-json",
"-n",
"1",
"--categories",
"person",
"--category-mode",
"annotations",
"--seed",
"1",
]);
cmd.assert().success();
let out_s = fs::read_to_string(&out).expect("read out");
let out_v: serde_json::Value = serde_json::from_str(&out_s).expect("parse json");
assert_eq!(out_v["categories"].as_array().map(|v| v.len()), Some(2));
let annotations = out_v["annotations"].as_array().expect("annotations array");
assert!(annotations.iter().all(|ann| ann["category_id"] == 1));
}
#[test]
fn sample_to_tfod_is_blocked_without_allow_lossy() {
let temp = tempfile::tempdir().expect("tempdir");
let out = temp.path().join("out.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"tfod",
"-n",
"1",
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("Lossy conversion"))
.stderr(predicates::str::contains("--allow-lossy"))
.stdout(predicates::str::contains("[drop_dataset_info]"))
.stdout(predicates::str::contains("Warnings"));
}
#[test]
fn sample_json_output_format_emits_report_only() {
let temp = tempfile::tempdir().expect("tempdir");
let out = temp.path().join("out.ir.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"ir-json",
"-n",
"1",
"--output-format",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
assert!(out.is_file());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert_eq!(parsed["from"], "coco");
assert_eq!(parsed["to"], "ir-json");
assert!(parsed.get("input").is_some());
assert!(parsed.get("output").is_some());
assert!(parsed.get("issues").is_some());
assert!(!stdout.contains("Sampled"));
}
#[test]
fn sample_dry_run_text_does_not_overwrite_existing_output() {
let temp = tempfile::tempdir().expect("tempdir");
let out = temp.path().join("existing.ir.json");
fs::write(&out, "keep me").expect("seed output");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"ir-json",
"-n",
"1",
"--seed",
"42",
"--dry-run",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dry run: would sample"))
.stdout(predicates::str::contains("images"))
.stdout(predicates::str::contains("annotations"));
assert_eq!(fs::read_to_string(&out).expect("read output"), "keep me");
}
#[test]
fn sample_dry_run_json_emits_report_only_and_skips_write() {
let temp = tempfile::tempdir().expect("tempdir");
let out = temp.path().join("dry_run.ir.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"ir-json",
"-n",
"1",
"--seed",
"42",
"--dry-run",
"--output-format",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
assert!(!out.exists());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert_eq!(parsed["from"], "coco");
assert_eq!(parsed["to"], "ir-json");
assert!(parsed.get("issues").is_some());
assert!(!stdout.contains("Sampled"));
assert!(!stdout.contains("Dry run"));
}
#[test]
fn sample_dry_run_blocked_lossy_still_errors_without_writing() {
let temp = tempfile::tempdir().expect("tempdir");
let out = temp.path().join("dry_run.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"tfod",
"-n",
"1",
"--seed",
"42",
"--dry-run",
]);
cmd.assert()
.failure()
.stdout(predicates::str::contains("[drop_dataset_info]"))
.stderr(predicates::str::contains("Lossy conversion"));
assert!(!out.exists());
}
#[test]
fn sample_dry_run_directory_target_does_not_create_output_dir() {
let temp = tempfile::tempdir().expect("tempdir");
let out = temp.path().join("dry_run_yolo");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"yolo",
"-n",
"1",
"--seed",
"42",
"--allow-lossy",
"--dry-run",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dry run: would sample"));
assert!(!out.exists());
}
#[test]
fn sample_dry_run_blocked_json_still_emits_report_without_writing() {
let temp = tempfile::tempdir().expect("tempdir");
let out = temp.path().join("dry_run.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"tfod",
"-n",
"1",
"--seed",
"42",
"--dry-run",
"--output-format",
"json",
]);
let output = cmd.output().expect("run command");
assert!(!output.status.success());
assert!(!out.exists());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert_eq!(parsed["from"], "coco");
assert_eq!(parsed["to"], "tfod");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Lossy conversion"));
}
#[test]
fn sample_blocked_json_emits_valid_json_to_stdout() {
let temp = tempfile::tempdir().expect("tempdir");
let out = temp.path().join("out.csv");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"tfod",
"-n",
"1",
"--output-format",
"json",
]);
let output = cmd.output().expect("run command");
assert!(!output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert_eq!(parsed["from"], "coco");
assert_eq!(parsed["to"], "tfod");
assert!(parsed["issues"].is_array());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Lossy conversion"));
assert!(stderr.contains("--allow-lossy"));
}
#[test]
fn sample_report_alias_json_works() {
let temp = tempfile::tempdir().expect("tempdir");
let out = temp.path().join("out.ir.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"sample",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
out.to_str().unwrap(),
"--from",
"coco",
"--to",
"ir-json",
"-n",
"1",
"--report",
"json",
]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
assert_eq!(parsed["to"], "ir-json");
}
#[test]
fn list_formats_shows_all_formats() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["list-formats"]);
cmd.assert()
.success()
.stdout(predicates::str::contains("ir-json"))
.stdout(predicates::str::contains("coco"))
.stdout(predicates::str::contains("cvat"))
.stdout(predicates::str::contains("label-studio"))
.stdout(predicates::str::contains("tfod"))
.stdout(predicates::str::contains("yolo"))
.stdout(predicates::str::contains("voc"))
.stdout(predicates::str::contains("hf"))
.stdout(predicates::str::contains("Supported formats"));
}
#[test]
fn list_formats_shows_lossiness() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["list-formats"]);
cmd.assert()
.success()
.stdout(predicates::str::contains("lossless"))
.stdout(predicates::str::contains("conditional"))
.stdout(predicates::str::contains("lossy"));
}
#[test]
fn list_formats_shows_read_write_capability() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["list-formats"]);
cmd.assert()
.success()
.stdout(predicates::str::contains("READ"))
.stdout(predicates::str::contains("WRITE"));
}
#[test]
fn list_formats_json_output_has_expected_schema() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["list-formats", "--output", "json"]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
let formats = parsed.as_array().expect("top-level array");
assert_eq!(formats.len(), 13);
let label_studio = formats
.iter()
.find(|entry| entry["name"] == "label-studio")
.expect("label-studio entry");
let label_studio_aliases = label_studio["aliases"]
.as_array()
.expect("aliases array")
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>();
assert!(label_studio_aliases.contains(&"label-studio-json"));
assert!(label_studio_aliases.contains(&"ls"));
let yolo = formats
.iter()
.find(|entry| entry["name"] == "yolo")
.expect("yolo entry");
assert_eq!(yolo["directory_based"], true);
assert_eq!(yolo["file_based"], false);
let coco = formats
.iter()
.find(|entry| entry["name"] == "coco")
.expect("coco entry");
assert_eq!(coco["file_based"], true);
assert_eq!(coco["directory_based"], false);
assert_eq!(coco["lossiness"], "conditional");
let ir_json = formats
.iter()
.find(|entry| entry["name"] == "ir-json")
.expect("ir-json entry");
assert_eq!(ir_json["lossiness"], "lossless");
assert_eq!(ir_json["read"], true);
assert_eq!(ir_json["write"], true);
for name in ["tfod", "yolo", "voc", "hf"] {
let entry = formats
.iter()
.find(|format| format["name"] == name)
.unwrap_or_else(|| panic!("missing {name} entry"));
assert_eq!(entry["lossiness"], "lossy");
}
}
#[test]
fn list_formats_output_format_alias_works() {
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["list-formats", "--output-format", "json"]);
let output = cmd.output().expect("run command");
assert!(output.status.success());
let (stdout, parsed) = stdout_json(&output);
assert_compact_json(&stdout);
let formats = parsed.as_array().expect("top-level array");
assert!(formats.iter().any(|entry| entry["name"] == "coco"));
}
#[test]
fn convert_auto_detects_coco_format() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("auto_detect_coco.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"ir-json",
"-i",
"tests/fixtures/sample_valid.coco.json",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(coco)"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_auto_detects_tfod_format() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("auto_detect_tfod.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
"tests/fixtures/sample_valid.tfod.csv",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(tfod)"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_auto_detects_ir_json_format() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("auto_detect_ir.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
"tests/fixtures/sample_valid.ir.json",
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(ir-json)"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_auto_detects_label_studio_format() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("auto_detect_label_studio.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
"tests/fixtures/sample_valid.label_studio.json",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(label-studio)"));
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn convert_auto_detects_yolo_directory() {
let temp = tempfile::tempdir().expect("create temp dir");
let yolo_dir = temp.path().join("sample_yolo");
create_sample_yolo_dataset(&yolo_dir);
let output_path = temp.path().join("auto_detect_yolo.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
yolo_dir.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(yolo)"));
}
#[test]
fn convert_auto_detects_yolo_labels_directory() {
let temp = tempfile::tempdir().expect("create temp dir");
let yolo_dir = temp.path().join("sample_yolo");
create_sample_yolo_dataset(&yolo_dir);
let labels_dir = yolo_dir.join("labels");
let output_path = temp.path().join("auto_detect_yolo_labels.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
labels_dir.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(yolo)"));
}
#[test]
fn convert_auto_detects_voc_directory() {
let temp = tempfile::tempdir().expect("create temp dir");
let voc_dir = temp.path().join("sample_voc");
create_sample_voc_dataset(&voc_dir);
let output_path = temp.path().join("auto_detect_voc.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
voc_dir.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(voc)"));
}
#[test]
fn convert_auto_detects_cvat_xml_file() {
let temp = tempfile::tempdir().expect("tempdir");
let output_path = temp.path().join("auto_detect_cvat_file.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
"tests/fixtures/sample_valid.cvat.xml",
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(cvat)"));
}
#[test]
fn convert_auto_detects_cvat_directory() {
let temp = tempfile::tempdir().expect("tempdir");
let cvat_dir = temp.path().join("sample_cvat");
create_sample_cvat_export(&cvat_dir);
let output_path = temp.path().join("auto_detect_cvat_dir.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
cvat_dir.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(cvat)"));
}
#[test]
fn convert_auto_detects_hf_directory() {
let temp = tempfile::tempdir().expect("tempdir");
let hf_dir = temp.path().join("sample_hf");
create_sample_hf_dataset(&hf_dir, false);
let output_path = temp.path().join("auto_detect_hf.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"ir-json",
"-i",
hf_dir.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(hf)"));
}
#[test]
fn convert_auto_detect_errors_on_hf_yolo_ambiguity() {
let temp = tempfile::tempdir().expect("create temp dir");
create_sample_hf_dataset(temp.path(), false);
fs::create_dir_all(temp.path().join("labels")).expect("create labels dir");
fs::create_dir_all(temp.path().join("images")).expect("create images dir");
fs::write(temp.path().join("labels/img1.txt"), "0 0.5 0.5 0.2 0.2\n").expect("write label");
let output_path = temp.path().join("auto_detect_ambiguous_hf_yolo.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
temp.path().to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("matches both YOLO and HF"));
}
#[test]
fn convert_auto_detect_partial_yolo_without_images_detects_hf() {
let temp = tempfile::tempdir().expect("create temp dir");
create_sample_hf_dataset(temp.path(), false);
fs::create_dir_all(temp.path().join("labels")).expect("create labels dir");
fs::write(temp.path().join("labels/img1.txt"), "0 0.5 0.5 0.2 0.2\n").expect("write label");
let output_path = temp.path().join("partial_yolo_hf.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"ir-json",
"-i",
temp.path().to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(hf)"));
}
#[test]
fn convert_auto_detect_partial_yolo_gives_helpful_error() {
let temp = tempfile::tempdir().expect("create temp dir");
fs::create_dir_all(temp.path().join("labels")).expect("create labels dir");
fs::write(temp.path().join("labels/img1.txt"), "0 0.5 0.5 0.2 0.2\n").expect("write label");
let output_path = temp.path().join("partial_yolo.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
temp.path().to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("YOLO"))
.stderr(predicates::str::contains("images/ directory"));
}
#[test]
fn convert_auto_detect_errors_on_yolo_voc_ambiguity() {
let temp = tempfile::tempdir().expect("create temp dir");
create_sample_yolo_dataset(temp.path());
fs::create_dir_all(temp.path().join("Annotations")).expect("create annotations dir");
fs::write(
temp.path().join("Annotations/extra.xml"),
r#"<?xml version="1.0" encoding="utf-8"?>
<annotation>
<filename>img_ambiguous.jpg</filename>
<size><width>10</width><height>10</height><depth>3</depth></size>
</annotation>
"#,
)
.expect("write xml");
fs::create_dir_all(temp.path().join("JPEGImages")).expect("create JPEGImages dir");
let output_path = temp.path().join("auto_detect_ambiguous.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
temp.path().to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("matches both YOLO and VOC"));
}
#[test]
fn convert_auto_fails_on_unknown_extension() {
let temp_dir = std::env::temp_dir();
let output_path = temp_dir.join("test_unknown_ext.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
"Cargo.toml",
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("unrecognized file extension"));
}
#[test]
fn convert_auto_detects_voc_without_jpegimages() {
let temp = tempfile::tempdir().expect("create temp dir");
let voc_dir = temp.path().join("voc_no_images");
fs::create_dir_all(voc_dir.join("Annotations")).expect("create annotations dir");
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<annotation>
<filename>img1.jpg</filename>
<size><width>100</width><height>80</height><depth>3</depth></size>
<object>
<name>person</name>
<bndbox><xmin>10</xmin><ymin>20</ymin><xmax>50</xmax><ymax>70</ymax></bndbox>
</object>
</annotation>
"#;
fs::write(voc_dir.join("Annotations/img1.xml"), xml).expect("write xml");
let output_path = temp.path().join("voc_no_images.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
voc_dir.to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
"--allow-lossy",
]);
cmd.assert()
.success()
.stdout(predicates::str::contains("(voc)"));
}
#[test]
fn stats_malformed_json_does_not_fall_back_to_ir() {
let temp = tempfile::tempdir().expect("tempdir");
let p = temp.path().join("bad.json");
fs::write(&p, "{ this is not valid json }").expect("write");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["stats", p.to_str().unwrap()]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("parse JSON"));
}
#[test]
fn stats_ambiguous_json_falls_back_to_ir() {
let temp = tempfile::tempdir().expect("tempdir");
let p = temp.path().join("empty_annotations.json");
fs::write(
&p,
r#"{"info":{},"images":[],"categories":[],"annotations":[]}"#,
)
.expect("write");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args(["stats", p.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicates::str::contains("Dataset Stats Report"));
}
#[test]
fn convert_auto_detect_unrecognized_dir_lists_expected_layouts() {
let temp = tempfile::tempdir().expect("tempdir");
let output_path = temp.path().join("output.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
temp.path().to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("unrecognized directory layout"))
.stderr(predicates::str::contains("YOLO"))
.stderr(predicates::str::contains("VOC"))
.stderr(predicates::str::contains("CVAT"))
.stderr(predicates::str::contains("HF"));
}
#[test]
fn convert_auto_detect_ambiguity_shows_evidence() {
let temp = tempfile::tempdir().expect("create temp dir");
create_sample_hf_dataset(temp.path(), false);
fs::create_dir_all(temp.path().join("labels")).expect("create labels dir");
fs::create_dir_all(temp.path().join("images")).expect("create images dir");
fs::write(temp.path().join("labels/img1.txt"), "0 0.5 0.5 0.2 0.2\n").expect("write label");
let output_path = temp.path().join("evidence.json");
let mut cmd = cargo_bin_cmd!("panlabel");
cmd.args([
"convert",
"--from",
"auto",
"--to",
"coco",
"-i",
temp.path().to_str().unwrap(),
"-o",
output_path.to_str().unwrap(),
]);
cmd.assert()
.failure()
.stderr(predicates::str::contains("labels/"))
.stderr(predicates::str::contains("metadata"));
}