Skip to main content

lib3mf_cli/commands/
batch.rs

1//! Batch command — process multiple 3MF/STL/OBJ files with a single invocation.
2//!
3//! This module implements the core batch engine for the `3mf batch` command.
4//! The batch pipeline:
5//! 1. Expand glob patterns, walk directories, deduplicate inputs
6//! 2. Detect file type from magic bytes (Zip3mf, Stl, Obj, Unknown)
7//! 3. Filter files by requested operations (e.g. validate only applies to 3MF)
8//! 4. Process files sequentially (jobs=1) or in parallel via rayon (jobs>1)
9//! 5. Accumulate per-file results — never abort on single failure
10//! 6. Emit text progress [N/M] or JSON Lines output per file
11//! 7. Optionally print summary (totals + failed list)
12//!
13//! SAFETY RULE: This module NEVER calls crate::commands::validate / stats / list.
14//! Those functions print directly to stdout and call std::process::exit().
15//! Instead, we call lib3mf-core APIs directly:
16//!   - model.validate(level)         → ValidationReport
17//!   - model.compute_stats(archiver) → ModelStats
18//!   - archiver.list_entries()       → `Vec<String>`
19
20// ---------------------------------------------------------------------------
21// (A) Imports
22// ---------------------------------------------------------------------------
23
24use 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// ---------------------------------------------------------------------------
40// (A) Core types
41// ---------------------------------------------------------------------------
42
43/// Detected type of a file based on magic bytes / extension.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum DetectedFileType {
47    /// ZIP-based 3MF container (PK\x03\x04 magic)
48    Zip3mf,
49    /// STL model (ASCII "solid" or binary header)
50    Stl,
51    /// Wavefront OBJ model (by .obj extension)
52    Obj,
53    /// Unrecognized or unsupported file type
54    Unknown,
55}
56
57/// Error category for per-operation failures.
58#[derive(Debug, Clone, Serialize)]
59#[serde(rename_all = "snake_case")]
60pub enum ErrorCategory {
61    /// I/O or archive error (cannot open, read, or parse file)
62    FileError,
63    /// Operation failed after the file was successfully opened
64    OperationError,
65}
66
67/// Error record for a single failed operation on a file.
68#[derive(Debug, Clone, Serialize)]
69pub struct FileError {
70    /// The broad category of the error (IO, Parse, Validation, etc.).
71    pub category: ErrorCategory,
72    /// The name of the operation that failed (e.g. "validate", "stats").
73    pub operation: String,
74    /// Human-readable description of the error.
75    pub message: String,
76}
77
78/// Result of processing one file.
79#[derive(Debug, Clone, Serialize)]
80pub struct FileResult {
81    /// 1-based index in the discovered file list
82    pub index: usize,
83    /// Absolute path to the file
84    pub path: PathBuf,
85    /// Detected file type
86    pub file_type: DetectedFileType,
87    /// True if this file was skipped (type not accepted by any requested op)
88    pub skipped: bool,
89    /// Accumulated errors from all operations
90    pub errors: Vec<FileError>,
91    /// Number of operations that completed without error
92    pub ops_completed: usize,
93    /// Per-operation JSON-serialisable results (keyed by operation name)
94    pub operations: HashMap<String, serde_json::Value>,
95}
96
97/// Set of operations to perform in a batch run.
98#[derive(Debug, Clone, Default)]
99pub struct BatchOps {
100    /// Run validation at this level (None = skip validate)
101    pub validate: bool,
102    /// Validation level string (minimal/standard/strict/paranoid)
103    pub validate_level: Option<String>,
104    /// Compute and emit model statistics
105    pub stats: bool,
106    /// List archive entries
107    pub list: bool,
108    /// Convert to another format
109    pub convert: bool,
110    /// Use ASCII format for STL conversion output (false = binary)
111    pub convert_ascii: bool,
112    /// Output directory for converted files
113    pub output_dir: Option<PathBuf>,
114}
115
116// ---------------------------------------------------------------------------
117// (B) File type detection and discovery
118// ---------------------------------------------------------------------------
119
120/// Reads magic bytes to classify a file's type.
121///
122/// - `PK\x03\x04` → Zip3mf
123/// - `solid` (case-insensitive, ASCII STL) → Stl
124/// - `.stl` extension fallback → Stl (binary STL has no reliable short magic)
125/// - `.obj` extension → Obj
126/// - `.3mf` extension → Zip3mf
127/// - Everything else → Unknown
128pub 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    // ZIP magic: PK\x03\x04
136    if n >= 4 && buf[0] == b'P' && buf[1] == b'K' && buf[2] == 0x03 && buf[3] == 0x04 {
137        return DetectedFileType::Zip3mf;
138    }
139
140    // ASCII STL starts with "solid" (trimmed, case-insensitive)
141    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    // Extension fallback (binary STL has no reliable short magic)
147    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
160/// Returns true if the operation set accepts the given file type.
161fn file_type_accepted(ft: DetectedFileType, ops: &BatchOps) -> bool {
162    match ft {
163        DetectedFileType::Zip3mf => {
164            // 3MF files support: validate, stats, list, convert
165            ops.validate || ops.stats || ops.list || ops.convert
166        }
167        DetectedFileType::Stl => {
168            // STL files support: validate (basic), stats, convert (to 3MF)
169            ops.validate || ops.stats || ops.convert
170        }
171        DetectedFileType::Obj => {
172            // OBJ files support: validate (basic), stats, convert (to 3MF)
173            ops.validate || ops.stats || ops.convert
174        }
175        DetectedFileType::Unknown => false,
176    }
177}
178
179/// Expands glob patterns, walks directories recursively (if recursive=true),
180/// and collects individual file paths. Deduplicates by canonical path.
181pub 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        // Try glob expansion first
189        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            // Not a glob — treat as literal path
203            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    // Use canonical path for dedup when possible
241    let key = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
242    if seen.insert(key) {
243        paths.push(path);
244    }
245}
246
247// ---------------------------------------------------------------------------
248// (C) process_file — per-file operation dispatch using lib3mf-core APIs directly
249// ---------------------------------------------------------------------------
250
251/// Processes a single file, running all requested operations.
252///
253/// NEVER calls crate::commands::validate / stats / list — those have side
254/// effects (process::exit). We call lib3mf-core APIs directly:
255///   - model.validate(level)           → ValidationReport
256///   - model.compute_stats(&archiver)  → ModelStats
257///   - archiver.list_entries()         → `Vec<String>`
258pub 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
288/// Process a 3MF (ZIP) file — supports validate, stats, list, convert.
289fn process_3mf_file(result: &mut FileResult, path: &Path, ops: &BatchOps) {
290    // Open archive — failure is a FileError that applies to all ops
291    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    // Parse model (needed for validate, stats, convert)
316    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                // Continue to list even if model parse fails
327                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    // validate operation — calls model.validate(level) directly
368    if ops.validate
369        && let Some(ref m) = model
370    {
371        run_validate_op(result, m, ops);
372    }
373
374    // stats operation — calls model.compute_stats(&mut archiver) directly
375    if ops.stats
376        && let Some(ref m) = model
377    {
378        run_stats_op(result, m, &mut archiver);
379    }
380
381    // list operation — calls archiver.list_entries() directly
382    if ops.list {
383        run_list_op(result, &mut archiver);
384    }
385
386    // convert operation: 3MF → STL
387    if ops.convert {
388        run_convert_3mf_op(result, model.as_ref(), path, ops);
389    }
390}
391
392/// Validate using model.validate(level) — returns ValidationReport directly.
393fn 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    // DIRECT API CALL: model.validate(level) — never calls commands::validate()
408    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
454/// Stats using model.compute_stats(&mut archiver) — returns ModelStats directly.
455fn run_stats_op(result: &mut FileResult, model: &Model, archiver: &mut ZipArchiver<File>) {
456    // DIRECT API CALL: model.compute_stats(archiver) — never calls commands::stats()
457    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
490/// List using archiver.list_entries() — returns Vec<String> directly.
491fn run_list_op(result: &mut FileResult, archiver: &mut ZipArchiver<File>) {
492    // DIRECT API CALL: archiver.list_entries() — never calls commands::list()
493    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
512/// Convert 3MF → STL (writes to output_dir if set, else same directory as source).
513fn 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
576/// Helper: count mesh triangles in a model from Geometry enum variants.
577fn 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
588/// Process an STL file — supports validate (basic), stats (via converter), convert (to 3MF).
589fn 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    // StlImporter::read takes a Read + Seek
603    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        // STL → 3MF
643        convert_to_3mf(result, &model, path, ops);
644    }
645}
646
647/// Process an OBJ file — supports validate (basic), stats (via converter), convert (to 3MF).
648fn 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        // OBJ → 3MF
690        convert_to_3mf(result, &model, path, ops);
691    }
692}
693
694/// Convert an in-memory model (parsed from STL/OBJ) to a 3MF archive.
695fn 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    // Use model.write() — the public API on Model (via model_write_zip.rs)
720    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
738// ---------------------------------------------------------------------------
739// (D) run() pipeline — main entry point
740// ---------------------------------------------------------------------------
741
742/// Top-level configuration for a batch run (passed to `run()`).
743pub struct BatchConfig {
744    /// Parallelism level (1 = sequential, >1 = rayon parallel)
745    pub jobs: usize,
746    /// Walk directories recursively
747    pub recursive: bool,
748    /// Print summary totals + failed list at end
749    pub summary: bool,
750    /// Verbosity level
751    pub verbosity: Verbosity,
752    /// Output format (text or JSON)
753    pub format: OutputFormat,
754    /// Suppress 100+ file warning
755    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
771/// Entry point for the batch command.
772///
773/// # Arguments
774/// - `inputs`  — Raw glob/path/directory inputs
775/// - `ops`     — Which operations to run
776/// - `config`  — Execution configuration (jobs, verbosity, format, etc.)
777///
778/// # Returns
779/// `Ok(true)` if all files processed without errors, `Ok(false)` if any failed.
780pub 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    // 1. Discover files
790    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    // 2. Warn if 100+ files
799    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    // 3. Process files (parallel or sequential)
812    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        // Sort by original index for deterministic output
826        collected.sort_by_key(|r| r.index);
827
828        // Emit progress after parallel collection
829        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        // Sequential: emit progress as we go
838        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    // 4. JSON Lines output (one JSON object per file per line)
852    if matches!(format, OutputFormat::Json) {
853        for res in &results {
854            println!("{}", serde_json::to_string(res)?);
855        }
856    }
857
858    // 5. Summary
859    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
873// ---------------------------------------------------------------------------
874// (E) Output formatting
875// ---------------------------------------------------------------------------
876
877/// Print progress for a single file in text mode.
878fn 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
911/// Print summary totals and failed file list to stderr.
912fn 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// ---------------------------------------------------------------------------
965// Tests
966// ---------------------------------------------------------------------------
967
968#[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        // PK\x03\x04 ZIP magic
976        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        // Provide the same file twice — should deduplicate
1040        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(); // no ops enabled
1088        let result = process_file(1, &p, &ops);
1089        assert!(result.skipped);
1090    }
1091}