1use crate::commands::OutputFormat;
25use crate::commands::merge::Verbosity;
26use glob::glob;
27use lib3mf_core::archive::{ArchiveReader, ZipArchiver, find_model_path};
28use lib3mf_core::model::{Geometry, Model};
29use lib3mf_core::parser::parse_model;
30use lib3mf_core::validation::ValidationLevel;
31use rayon::prelude::*;
32use serde::Serialize;
33use std::collections::HashMap;
34use std::fs::File;
35use std::io::Read;
36use std::path::{Path, PathBuf};
37use walkdir::WalkDir;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum DetectedFileType {
47 Zip3mf,
49 Stl,
51 Obj,
53 Unknown,
55}
56
57#[derive(Debug, Clone, Serialize)]
59#[serde(rename_all = "snake_case")]
60pub enum ErrorCategory {
61 FileError,
63 OperationError,
65}
66
67#[derive(Debug, Clone, Serialize)]
69pub struct FileError {
70 pub category: ErrorCategory,
72 pub operation: String,
74 pub message: String,
76}
77
78#[derive(Debug, Clone, Serialize)]
80pub struct FileResult {
81 pub index: usize,
83 pub path: PathBuf,
85 pub file_type: DetectedFileType,
87 pub skipped: bool,
89 pub errors: Vec<FileError>,
91 pub ops_completed: usize,
93 pub operations: HashMap<String, serde_json::Value>,
95}
96
97#[derive(Debug, Clone, Default)]
99pub struct BatchOps {
100 pub validate: bool,
102 pub validate_level: Option<String>,
104 pub stats: bool,
106 pub list: bool,
108 pub convert: bool,
110 pub convert_ascii: bool,
112 pub output_dir: Option<PathBuf>,
114}
115
116pub fn detect_file_type(path: &Path) -> DetectedFileType {
129 let mut buf = [0u8; 16];
130 let Ok(mut f) = File::open(path) else {
131 return DetectedFileType::Unknown;
132 };
133 let n = f.read(&mut buf).unwrap_or(0);
134
135 if n >= 4 && buf[0] == b'P' && buf[1] == b'K' && buf[2] == 0x03 && buf[3] == 0x04 {
137 return DetectedFileType::Zip3mf;
138 }
139
140 let as_lower: Vec<u8> = buf[..n].iter().map(|b| b.to_ascii_lowercase()).collect();
142 if as_lower.starts_with(b"solid") {
143 return DetectedFileType::Stl;
144 }
145
146 if let Some(ext) = path.extension() {
148 let ext_lower = ext.to_string_lossy().to_lowercase();
149 match ext_lower.as_str() {
150 "stl" => return DetectedFileType::Stl,
151 "obj" => return DetectedFileType::Obj,
152 "3mf" => return DetectedFileType::Zip3mf,
153 _ => {}
154 }
155 }
156
157 DetectedFileType::Unknown
158}
159
160fn file_type_accepted(ft: DetectedFileType, ops: &BatchOps) -> bool {
162 match ft {
163 DetectedFileType::Zip3mf => {
164 ops.validate || ops.stats || ops.list || ops.convert
166 }
167 DetectedFileType::Stl => {
168 ops.validate || ops.stats || ops.convert
170 }
171 DetectedFileType::Obj => {
172 ops.validate || ops.stats || ops.convert
174 }
175 DetectedFileType::Unknown => false,
176 }
177}
178
179pub fn discover_files(raw_inputs: &[PathBuf], recursive: bool) -> anyhow::Result<Vec<PathBuf>> {
182 let mut paths: Vec<PathBuf> = Vec::new();
183 let mut seen = std::collections::HashSet::new();
184
185 for input in raw_inputs {
186 let input_str = input.to_string_lossy();
187
188 let glob_matches: Vec<PathBuf> = glob(&input_str)
190 .map(|g| g.filter_map(|r| r.ok()).collect())
191 .unwrap_or_default();
192
193 if !glob_matches.is_empty() {
194 for p in glob_matches {
195 if p.is_dir() {
196 collect_from_dir(&p, recursive, &mut paths, &mut seen);
197 } else {
198 insert_unique(p, &mut paths, &mut seen);
199 }
200 }
201 } else {
202 let p = input.clone();
204 if p.is_dir() {
205 collect_from_dir(&p, recursive, &mut paths, &mut seen);
206 } else if p.exists() {
207 insert_unique(p, &mut paths, &mut seen);
208 }
209 }
210 }
211
212 Ok(paths)
213}
214
215fn collect_from_dir(
216 dir: &Path,
217 recursive: bool,
218 paths: &mut Vec<PathBuf>,
219 seen: &mut std::collections::HashSet<PathBuf>,
220) {
221 let walker = if recursive {
222 WalkDir::new(dir)
223 } else {
224 WalkDir::new(dir).max_depth(1)
225 };
226
227 for entry in walker.into_iter().filter_map(|e| e.ok()) {
228 let p = entry.path().to_path_buf();
229 if p.is_file() {
230 insert_unique(p, paths, seen);
231 }
232 }
233}
234
235fn insert_unique(
236 path: PathBuf,
237 paths: &mut Vec<PathBuf>,
238 seen: &mut std::collections::HashSet<PathBuf>,
239) {
240 let key = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
242 if seen.insert(key) {
243 paths.push(path);
244 }
245}
246
247pub fn process_file(index: usize, path: &Path, ops: &BatchOps) -> FileResult {
259 let file_type = detect_file_type(path);
260
261 let mut result = FileResult {
262 index,
263 path: path.to_path_buf(),
264 file_type,
265 skipped: false,
266 errors: Vec::new(),
267 ops_completed: 0,
268 operations: HashMap::new(),
269 };
270
271 if !file_type_accepted(file_type, ops) {
272 result.skipped = true;
273 return result;
274 }
275
276 match file_type {
277 DetectedFileType::Zip3mf => process_3mf_file(&mut result, path, ops),
278 DetectedFileType::Stl => process_stl_file(&mut result, path, ops),
279 DetectedFileType::Obj => process_obj_file(&mut result, path, ops),
280 DetectedFileType::Unknown => {
281 result.skipped = true;
282 }
283 }
284
285 result
286}
287
288fn process_3mf_file(result: &mut FileResult, path: &Path, ops: &BatchOps) {
290 let file = match File::open(path) {
292 Ok(f) => f,
293 Err(e) => {
294 result.errors.push(FileError {
295 category: ErrorCategory::FileError,
296 operation: "open".to_string(),
297 message: e.to_string(),
298 });
299 return;
300 }
301 };
302
303 let mut archiver = match ZipArchiver::new(file) {
304 Ok(a) => a,
305 Err(e) => {
306 result.errors.push(FileError {
307 category: ErrorCategory::FileError,
308 operation: "open_zip".to_string(),
309 message: e.to_string(),
310 });
311 return;
312 }
313 };
314
315 let model_needed = ops.validate || ops.stats || ops.convert;
317 let model: Option<Model> = if model_needed {
318 let model_path = match find_model_path(&mut archiver) {
319 Ok(p) => p,
320 Err(e) => {
321 result.errors.push(FileError {
322 category: ErrorCategory::FileError,
323 operation: "find_model_path".to_string(),
324 message: e.to_string(),
325 });
326 if ops.list {
328 run_list_op(result, &mut archiver);
329 }
330 return;
331 }
332 };
333
334 let model_data = match archiver.read_entry(&model_path) {
335 Ok(d) => d,
336 Err(e) => {
337 result.errors.push(FileError {
338 category: ErrorCategory::FileError,
339 operation: "read_model".to_string(),
340 message: e.to_string(),
341 });
342 if ops.list {
343 run_list_op(result, &mut archiver);
344 }
345 return;
346 }
347 };
348
349 match parse_model(std::io::Cursor::new(model_data)) {
350 Ok(m) => Some(m),
351 Err(e) => {
352 result.errors.push(FileError {
353 category: ErrorCategory::FileError,
354 operation: "parse_model".to_string(),
355 message: e.to_string(),
356 });
357 if ops.list {
358 run_list_op(result, &mut archiver);
359 }
360 return;
361 }
362 }
363 } else {
364 None
365 };
366
367 if ops.validate
369 && let Some(ref m) = model
370 {
371 run_validate_op(result, m, ops);
372 }
373
374 if ops.stats
376 && let Some(ref m) = model
377 {
378 run_stats_op(result, m, &mut archiver);
379 }
380
381 if ops.list {
383 run_list_op(result, &mut archiver);
384 }
385
386 if ops.convert {
388 run_convert_3mf_op(result, model.as_ref(), path, ops);
389 }
390}
391
392fn run_validate_op(result: &mut FileResult, model: &Model, ops: &BatchOps) {
394 let level = match ops
395 .validate_level
396 .as_deref()
397 .unwrap_or("standard")
398 .to_ascii_lowercase()
399 .as_str()
400 {
401 "minimal" => ValidationLevel::Minimal,
402 "strict" => ValidationLevel::Strict,
403 "paranoid" => ValidationLevel::Paranoid,
404 _ => ValidationLevel::Standard,
405 };
406
407 let report = model.validate(level);
409
410 let errors: Vec<serde_json::Value> = report
411 .items
412 .iter()
413 .filter(|i| i.severity == lib3mf_core::validation::ValidationSeverity::Error)
414 .map(|i| serde_json::json!({ "code": i.code, "message": i.message }))
415 .collect();
416 let warnings: Vec<serde_json::Value> = report
417 .items
418 .iter()
419 .filter(|i| i.severity == lib3mf_core::validation::ValidationSeverity::Warning)
420 .map(|i| serde_json::json!({ "code": i.code, "message": i.message }))
421 .collect();
422 let info: Vec<serde_json::Value> = report
423 .items
424 .iter()
425 .filter(|i| i.severity == lib3mf_core::validation::ValidationSeverity::Info)
426 .map(|i| serde_json::json!({ "code": i.code, "message": i.message }))
427 .collect();
428
429 let passed = !report.has_errors();
430 let error_count = errors.len();
431
432 result.operations.insert(
433 "validate".to_string(),
434 serde_json::json!({
435 "passed": passed,
436 "level": format!("{level:?}").to_lowercase(),
437 "errors": errors,
438 "warnings": warnings,
439 "info": info,
440 }),
441 );
442
443 if !passed {
444 result.errors.push(FileError {
445 category: ErrorCategory::OperationError,
446 operation: "validate".to_string(),
447 message: format!("Validation failed: {error_count} error(s)"),
448 });
449 } else {
450 result.ops_completed += 1;
451 }
452}
453
454fn run_stats_op(result: &mut FileResult, model: &Model, archiver: &mut ZipArchiver<File>) {
456 match model.compute_stats(archiver) {
458 Ok(stats) => {
459 let v = serde_json::json!({
460 "geometry": {
461 "object_count": stats.geometry.object_count,
462 "triangle_count": stats.geometry.triangle_count,
463 "vertex_count": stats.geometry.vertex_count,
464 "instance_count": stats.geometry.instance_count,
465 "surface_area": stats.geometry.surface_area,
466 "volume": stats.geometry.volume,
467 "is_manifold": stats.geometry.is_manifold,
468 },
469 "materials": {
470 "base_materials_count": stats.materials.base_materials_count,
471 "color_groups_count": stats.materials.color_groups_count,
472 "texture_2d_groups_count": stats.materials.texture_2d_groups_count,
473 "composite_materials_count": stats.materials.composite_materials_count,
474 "multi_properties_count": stats.materials.multi_properties_count,
475 },
476 });
477 result.operations.insert("stats".to_string(), v);
478 result.ops_completed += 1;
479 }
480 Err(e) => {
481 result.errors.push(FileError {
482 category: ErrorCategory::OperationError,
483 operation: "stats".to_string(),
484 message: e.to_string(),
485 });
486 }
487 }
488}
489
490fn run_list_op(result: &mut FileResult, archiver: &mut ZipArchiver<File>) {
492 match archiver.list_entries() {
494 Ok(entries) => {
495 let count = entries.len();
496 result.operations.insert(
497 "list".to_string(),
498 serde_json::json!({ "entries": entries, "count": count }),
499 );
500 result.ops_completed += 1;
501 }
502 Err(e) => {
503 result.errors.push(FileError {
504 category: ErrorCategory::OperationError,
505 operation: "list".to_string(),
506 message: e.to_string(),
507 });
508 }
509 }
510}
511
512fn run_convert_3mf_op(
514 result: &mut FileResult,
515 model: Option<&Model>,
516 source_path: &Path,
517 ops: &BatchOps,
518) {
519 let Some(model) = model else {
520 result.errors.push(FileError {
521 category: ErrorCategory::OperationError,
522 operation: "convert".to_string(),
523 message: "Model not loaded; cannot convert".to_string(),
524 });
525 return;
526 };
527
528 let stem = source_path
529 .file_stem()
530 .map(|s| s.to_string_lossy().into_owned())
531 .unwrap_or_else(|| "output".to_string());
532
533 let out_name = format!("{}.stl", stem);
534 let out_dir = ops
535 .output_dir
536 .as_deref()
537 .unwrap_or_else(|| source_path.parent().unwrap_or(Path::new(".")));
538 let out_path = out_dir.join(&out_name);
539
540 let out_file = match File::create(&out_path) {
541 Ok(f) => f,
542 Err(e) => {
543 result.errors.push(FileError {
544 category: ErrorCategory::OperationError,
545 operation: "convert".to_string(),
546 message: format!("Cannot create {}: {}", out_path.display(), e),
547 });
548 return;
549 }
550 };
551
552 let write_result = if ops.convert_ascii {
553 lib3mf_converters::stl::AsciiStlExporter::write(model, out_file)
554 } else {
555 lib3mf_converters::stl::BinaryStlExporter::write(model, out_file)
556 };
557
558 match write_result {
559 Ok(()) => {
560 result.operations.insert(
561 "convert".to_string(),
562 serde_json::json!({ "output": out_path.display().to_string() }),
563 );
564 result.ops_completed += 1;
565 }
566 Err(e) => {
567 result.errors.push(FileError {
568 category: ErrorCategory::OperationError,
569 operation: "convert".to_string(),
570 message: e.to_string(),
571 });
572 }
573 }
574}
575
576fn count_triangles_vertices(model: &Model) -> (usize, usize) {
578 model
579 .resources
580 .iter_objects()
581 .map(|o| match &o.geometry {
582 Geometry::Mesh(m) => (m.triangles.len(), m.vertices.len()),
583 _ => (0, 0),
584 })
585 .fold((0, 0), |(ta, va), (t, v)| (ta + t, va + v))
586}
587
588fn process_stl_file(result: &mut FileResult, path: &Path, ops: &BatchOps) {
590 let file = match File::open(path) {
591 Ok(f) => f,
592 Err(e) => {
593 result.errors.push(FileError {
594 category: ErrorCategory::FileError,
595 operation: "open".to_string(),
596 message: e.to_string(),
597 });
598 return;
599 }
600 };
601
602 let model = match lib3mf_converters::stl::StlImporter::read(file) {
604 Ok(m) => m,
605 Err(e) => {
606 result.errors.push(FileError {
607 category: ErrorCategory::FileError,
608 operation: "parse_stl".to_string(),
609 message: e.to_string(),
610 });
611 return;
612 }
613 };
614
615 if ops.validate {
616 run_validate_op(result, &model, ops);
617 }
618
619 if ops.stats {
620 let obj_count = model.resources.iter_objects().count();
621 let (tri_count, vert_count) = count_triangles_vertices(&model);
622 result.operations.insert(
623 "stats".to_string(),
624 serde_json::json!({
625 "geometry": {
626 "object_count": obj_count,
627 "triangle_count": tri_count,
628 "vertex_count": vert_count,
629 "instance_count": model.build.items.len(),
630 },
631 "materials": {
632 "base_materials_count": 0,
633 "color_groups_count": 0,
634 "texture_2d_groups_count": 0,
635 },
636 }),
637 );
638 result.ops_completed += 1;
639 }
640
641 if ops.convert {
642 convert_to_3mf(result, &model, path, ops);
644 }
645}
646
647fn process_obj_file(result: &mut FileResult, path: &Path, ops: &BatchOps) {
649 let model = match lib3mf_converters::obj::ObjImporter::read_from_path(path) {
650 Ok(m) => m,
651 Err(e) => {
652 result.errors.push(FileError {
653 category: ErrorCategory::FileError,
654 operation: "parse_obj".to_string(),
655 message: e.to_string(),
656 });
657 return;
658 }
659 };
660
661 if ops.validate {
662 run_validate_op(result, &model, ops);
663 }
664
665 if ops.stats {
666 let obj_count = model.resources.iter_objects().count();
667 let (tri_count, vert_count) = count_triangles_vertices(&model);
668 let base_mat_count = model.resources.iter_base_materials().count();
669 result.operations.insert(
670 "stats".to_string(),
671 serde_json::json!({
672 "geometry": {
673 "object_count": obj_count,
674 "triangle_count": tri_count,
675 "vertex_count": vert_count,
676 "instance_count": model.build.items.len(),
677 },
678 "materials": {
679 "base_materials_count": base_mat_count,
680 "color_groups_count": 0,
681 "texture_2d_groups_count": 0,
682 },
683 }),
684 );
685 result.ops_completed += 1;
686 }
687
688 if ops.convert {
689 convert_to_3mf(result, &model, path, ops);
691 }
692}
693
694fn convert_to_3mf(result: &mut FileResult, model: &Model, source_path: &Path, ops: &BatchOps) {
696 let stem = source_path
697 .file_stem()
698 .map(|s| s.to_string_lossy().into_owned())
699 .unwrap_or_else(|| "output".to_string());
700
701 let out_dir = ops
702 .output_dir
703 .as_deref()
704 .unwrap_or_else(|| source_path.parent().unwrap_or(Path::new(".")));
705 let out_path = out_dir.join(format!("{}.3mf", stem));
706
707 let out_file = match File::create(&out_path) {
708 Ok(f) => f,
709 Err(e) => {
710 result.errors.push(FileError {
711 category: ErrorCategory::OperationError,
712 operation: "convert".to_string(),
713 message: format!("Cannot create {}: {}", out_path.display(), e),
714 });
715 return;
716 }
717 };
718
719 match model.write(out_file) {
721 Ok(()) => {
722 result.operations.insert(
723 "convert".to_string(),
724 serde_json::json!({ "output": out_path.display().to_string() }),
725 );
726 result.ops_completed += 1;
727 }
728 Err(e) => {
729 result.errors.push(FileError {
730 category: ErrorCategory::OperationError,
731 operation: "convert".to_string(),
732 message: e.to_string(),
733 });
734 }
735 }
736}
737
738pub struct BatchConfig {
744 pub jobs: usize,
746 pub recursive: bool,
748 pub summary: bool,
750 pub verbosity: Verbosity,
752 pub format: OutputFormat,
754 pub yes: bool,
756}
757
758impl Default for BatchConfig {
759 fn default() -> Self {
760 BatchConfig {
761 jobs: 1,
762 recursive: false,
763 summary: false,
764 verbosity: Verbosity::Normal,
765 format: OutputFormat::Text,
766 yes: false,
767 }
768 }
769}
770
771pub fn run(inputs: Vec<PathBuf>, ops: BatchOps, config: BatchConfig) -> anyhow::Result<bool> {
781 let BatchConfig {
782 jobs,
783 recursive,
784 summary,
785 verbosity,
786 format,
787 yes,
788 } = config;
789 let files = discover_files(&inputs, recursive)?;
791 let total = files.len();
792
793 if total == 0 {
794 eprintln!("No files found matching the given inputs.");
795 return Ok(true);
796 }
797
798 if total >= 100 && !yes {
800 eprintln!(
801 "Warning: {} files discovered. Processing this many files may take a while.",
802 total
803 );
804 eprintln!("Pass --yes to suppress this warning and proceed.");
805 }
806
807 if matches!(verbosity, Verbosity::Verbose) {
808 eprintln!("Batch processing {} file(s) with jobs={}", total, jobs);
809 }
810
811 let results: Vec<FileResult> = if jobs > 1 {
813 let pool = rayon::ThreadPoolBuilder::new()
814 .num_threads(jobs)
815 .build()
816 .unwrap_or_else(|_| rayon::ThreadPoolBuilder::new().build().unwrap());
817
818 let mut collected: Vec<FileResult> = pool.install(|| {
819 files
820 .par_iter()
821 .enumerate()
822 .map(|(i, path)| process_file(i + 1, path, &ops))
823 .collect()
824 });
825 collected.sort_by_key(|r| r.index);
827
828 if !matches!(format, OutputFormat::Json) && !matches!(verbosity, Verbosity::Quiet) {
830 for res in &collected {
831 print_file_progress(res, res.index, total, &verbosity);
832 }
833 }
834
835 collected
836 } else {
837 files
839 .iter()
840 .enumerate()
841 .map(|(i, path)| {
842 let res = process_file(i + 1, path, &ops);
843 if !matches!(format, OutputFormat::Json) && !matches!(verbosity, Verbosity::Quiet) {
844 print_file_progress(&res, i + 1, total, &verbosity);
845 }
846 res
847 })
848 .collect()
849 };
850
851 if matches!(format, OutputFormat::Json) {
853 for res in &results {
854 println!("{}", serde_json::to_string(res)?);
855 }
856 }
857
858 let failed: Vec<&FileResult> = results.iter().filter(|r| !r.errors.is_empty()).collect();
860 let skipped: Vec<&FileResult> = results.iter().filter(|r| r.skipped).collect();
861 let succeeded = results
862 .iter()
863 .filter(|r| r.errors.is_empty() && !r.skipped)
864 .count();
865
866 if summary {
867 print_summary(total, succeeded, skipped.len(), &failed, &format);
868 }
869
870 Ok(failed.is_empty())
871}
872
873fn print_file_progress(res: &FileResult, index: usize, total: usize, verbosity: &Verbosity) {
879 let status = if res.skipped {
880 "SKIP".to_string()
881 } else if res.errors.is_empty() {
882 "OK".to_string()
883 } else {
884 format!("FAIL({})", res.errors.len())
885 };
886
887 println!(
888 "[{}/{}] {} -- {:?} -- {}",
889 index,
890 total,
891 res.path.display(),
892 res.file_type,
893 status
894 );
895
896 if matches!(verbosity, Verbosity::Verbose) {
897 for (op, val) in &res.operations {
898 println!(" {}: {}", op, val);
899 }
900 for err in &res.errors {
901 eprintln!(
902 " ERROR ({}/{}): {}",
903 err.operation,
904 format_category(&err.category),
905 err.message
906 );
907 }
908 }
909}
910
911fn print_summary(
913 total: usize,
914 succeeded: usize,
915 skipped: usize,
916 failed: &[&FileResult],
917 format: &OutputFormat,
918) {
919 if matches!(format, OutputFormat::Json) {
920 let failed_paths: Vec<String> = failed
921 .iter()
922 .map(|r| r.path.display().to_string())
923 .collect();
924 let summary = serde_json::json!({
925 "summary": {
926 "total": total,
927 "succeeded": succeeded,
928 "skipped": skipped,
929 "failed": failed.len(),
930 "failed_files": failed_paths,
931 }
932 });
933 eprintln!("{summary}");
934 } else {
935 eprintln!("--- Batch Summary ---");
936 eprintln!("Total: {total}");
937 eprintln!("Succeeded: {succeeded}");
938 eprintln!("Skipped: {skipped}");
939 eprintln!("Failed: {}", failed.len());
940 if !failed.is_empty() {
941 eprintln!("Failed files:");
942 for r in failed {
943 eprintln!(" {}", r.path.display());
944 for e in &r.errors {
945 eprintln!(
946 " [{}] {}: {}",
947 format_category(&e.category),
948 e.operation,
949 e.message
950 );
951 }
952 }
953 }
954 }
955}
956
957fn format_category(cat: &ErrorCategory) -> &'static str {
958 match cat {
959 ErrorCategory::FileError => "file-error",
960 ErrorCategory::OperationError => "op-error",
961 }
962}
963
964#[cfg(test)]
969mod tests {
970 use super::*;
971 use std::io::Write;
972 use tempfile::TempDir;
973
974 fn make_zip_file(dir: &Path, name: &str) -> PathBuf {
975 let path = dir.join(name);
977 let mut f = File::create(&path).unwrap();
978 f.write_all(b"PK\x03\x04\x00\x00\x00\x00").unwrap();
979 path
980 }
981
982 fn make_ascii_stl_file(dir: &Path, name: &str) -> PathBuf {
983 let path = dir.join(name);
984 let mut f = File::create(&path).unwrap();
985 f.write_all(b"solid test\nendsolid test\n").unwrap();
986 path
987 }
988
989 fn make_txt_file(dir: &Path, name: &str) -> PathBuf {
990 let path = dir.join(name);
991 let mut f = File::create(&path).unwrap();
992 f.write_all(b"not a 3D file").unwrap();
993 path
994 }
995
996 #[test]
997 fn test_detect_file_type_zip() {
998 let dir = TempDir::new().unwrap();
999 let p = make_zip_file(dir.path(), "model.3mf");
1000 assert_eq!(detect_file_type(&p), DetectedFileType::Zip3mf);
1001 }
1002
1003 #[test]
1004 fn test_detect_file_type_ascii_stl() {
1005 let dir = TempDir::new().unwrap();
1006 let p = make_ascii_stl_file(dir.path(), "model.stl");
1007 assert_eq!(detect_file_type(&p), DetectedFileType::Stl);
1008 }
1009
1010 #[test]
1011 fn test_detect_file_type_extension_fallback_obj() {
1012 let dir = TempDir::new().unwrap();
1013 let path = dir.path().join("model.obj");
1014 let mut f = File::create(&path).unwrap();
1015 f.write_all(b"v 0 0 0\n").unwrap();
1016 assert_eq!(detect_file_type(&path), DetectedFileType::Obj);
1017 }
1018
1019 #[test]
1020 fn test_detect_file_type_unknown() {
1021 let dir = TempDir::new().unwrap();
1022 let p = make_txt_file(dir.path(), "readme.txt");
1023 assert_eq!(detect_file_type(&p), DetectedFileType::Unknown);
1024 }
1025
1026 #[test]
1027 fn test_discover_files_single() {
1028 let dir = TempDir::new().unwrap();
1029 let p = make_zip_file(dir.path(), "a.3mf");
1030 let discovered = discover_files(&[p.clone()], false).unwrap();
1031 assert_eq!(discovered.len(), 1);
1032 assert_eq!(discovered[0], p);
1033 }
1034
1035 #[test]
1036 fn test_discover_files_dedup() {
1037 let dir = TempDir::new().unwrap();
1038 let p = make_zip_file(dir.path(), "a.3mf");
1039 let discovered = discover_files(&[p.clone(), p.clone()], false).unwrap();
1041 assert_eq!(discovered.len(), 1);
1042 }
1043
1044 #[test]
1045 fn test_discover_files_directory() {
1046 let dir = TempDir::new().unwrap();
1047 make_zip_file(dir.path(), "a.3mf");
1048 make_zip_file(dir.path(), "b.3mf");
1049 let discovered = discover_files(&[dir.path().to_path_buf()], false).unwrap();
1050 assert_eq!(discovered.len(), 2);
1051 }
1052
1053 #[test]
1054 fn test_file_type_accepted_zip3mf() {
1055 let ops = BatchOps {
1056 validate: true,
1057 ..Default::default()
1058 };
1059 assert!(file_type_accepted(DetectedFileType::Zip3mf, &ops));
1060 assert!(!file_type_accepted(DetectedFileType::Unknown, &ops));
1061 }
1062
1063 #[test]
1064 fn test_file_type_not_accepted_when_no_ops() {
1065 let ops = BatchOps::default();
1066 assert!(!file_type_accepted(DetectedFileType::Zip3mf, &ops));
1067 assert!(!file_type_accepted(DetectedFileType::Stl, &ops));
1068 }
1069
1070 #[test]
1071 fn test_process_file_skipped_for_unknown_type() {
1072 let dir = TempDir::new().unwrap();
1073 let p = make_txt_file(dir.path(), "readme.txt");
1074 let ops = BatchOps {
1075 validate: true,
1076 ..Default::default()
1077 };
1078 let result = process_file(1, &p, &ops);
1079 assert!(result.skipped);
1080 assert!(result.errors.is_empty());
1081 }
1082
1083 #[test]
1084 fn test_process_file_skipped_when_no_ops() {
1085 let dir = TempDir::new().unwrap();
1086 let p = make_zip_file(dir.path(), "model.3mf");
1087 let ops = BatchOps::default(); let result = process_file(1, &p, &ops);
1089 assert!(result.skipped);
1090 }
1091}