Skip to main content

text_mirror/
pipeline.rs

1//! The end-to-end pipeline behind the CLI verbs.
2//!
3//! `run` is incremental and idempotent. The manifest is the
4//! checkpoint. A prior terminal record whose key of source path,
5//! source hash, converter version, and rules version matches the
6//! current pass, and whose artifact still hashes to the recorded
7//! text hash, becomes a `skipped_unchanged` record with no work done.
8//! Failed and unsupported sources are re-evaluated every run, so their
9//! terminal records keep the current reason and the enumerated
10//! remainder stays visible.
11//!
12//! Per-source problems, meaning IO errors, converter errors, and
13//! mirror write failures, become `failed` records and the run
14//! continues. Only run-level problems abort: a walk failure, a rules
15//! failure, an invalid layout, or a manifest open or append failure.
16
17use std::collections::{BTreeMap, HashMap};
18use std::fs;
19use std::path::{Path, PathBuf};
20use std::time::Instant;
21
22use crate::convert::{self, Registry};
23use crate::detect::{self, Detection, FormatTable, UNKNOWN_FORMAT};
24use crate::hash::{self, CanonicalArtifact, DedupIndex};
25use crate::manifest::{self, ArtifactKind, ManifestSchema, ManifestWriter, Record, Status};
26use crate::mirror;
27use crate::report::{
28    DivisionStatus, ExplainEntry, FormatCount, RunReport, ScanReport, SizeOutlier, StatusCounts,
29    StatusReport,
30};
31use crate::segments;
32use crate::walk::{self, EntryKind, WalkOptions};
33use crate::{Error, Result};
34
35/// The detected format recorded for a symlink entry.
36pub const SYMLINK_FORMAT: &str = "symlink";
37
38/// The detected format recorded in scan output for FIFOs and sockets.
39pub const SPECIAL_FORMAT: &str = "special";
40
41/// The rules files loaded for one run.
42///
43/// The format table and the converter registry must declare the same
44/// version. That shared version is recorded in every record as
45/// `rules_version`.
46pub struct Rules {
47    /// The format table from `rules/formats.toml`.
48    pub table: FormatTable,
49    /// The converter registry from `rules/converters.toml`.
50    pub registry: Registry,
51}
52
53impl Rules {
54    /// The rules compiled into the binary.
55    pub fn builtin() -> Result<Self> {
56        let table = FormatTable::builtin()?;
57        let registry = Registry::builtin()?;
58        if table.version() != registry.version() {
59            return Err(Error::Rules {
60                name: "rules".to_string(),
61                message: format!(
62                    "formats.toml version {:?} does not match converters.toml version {:?}",
63                    table.version(),
64                    registry.version()
65                ),
66            });
67        }
68        Ok(Rules { table, registry })
69    }
70
71    /// The shared rules version.
72    pub fn version(&self) -> &str {
73        self.table.version()
74    }
75}
76
77/// Options for one conversion run.
78pub struct RunOptions<'a> {
79    /// The division root to walk.
80    pub root: &'a Path,
81    /// The mirror tree root.
82    pub mirror_root: &'a Path,
83    /// The directory holding manifest shards.
84    pub manifest_dir: &'a Path,
85    /// The division name, also the shard file stem.
86    pub division: &'a str,
87    /// Traversal options.
88    pub walk: WalkOptions,
89}
90
91/// Inventories a division root without converting anything.
92///
93/// A file is a size outlier when it is at least 1 MiB and at least ten
94/// times the mean file size of the tree. Symlinks appear under the
95/// `symlink` format and FIFOs and sockets under `special`.
96pub fn scan(root: &Path, rules: &Rules, options: &WalkOptions) -> Result<ScanReport> {
97    let entries = walk::walk_division(root, options)?;
98    let mut inventory: Vec<(String, u64, String)> = Vec::new();
99    for entry in &entries {
100        let absolute = root.join(&entry.path);
101        let (size, format) = match entry.kind {
102            EntryKind::File => {
103                let size = absolute
104                    .metadata()
105                    .map_err(|e| Error::io("stat", &absolute, e))?
106                    .len();
107                let detection = detect::detect_file(&absolute, &rules.table)?;
108                (size, detection.detected)
109            }
110            EntryKind::Symlink => {
111                let size = fs::symlink_metadata(&absolute)
112                    .map_err(|e| Error::io("stat", &absolute, e))?
113                    .len();
114                (size, SYMLINK_FORMAT.to_string())
115            }
116            EntryKind::Other => (0, SPECIAL_FORMAT.to_string()),
117        };
118        inventory.push((entry.path.to_string_lossy().into_owned(), size, format));
119    }
120
121    let mut by_format: BTreeMap<String, (u64, u64)> = BTreeMap::new();
122    let mut total_bytes = 0u64;
123    for (_, size, format) in &inventory {
124        let entry = by_format.entry(format.clone()).or_insert((0, 0));
125        entry.0 += 1;
126        entry.1 += size;
127        total_bytes += size;
128    }
129
130    let mean = if inventory.is_empty() {
131        0.0
132    } else {
133        total_bytes as f64 / inventory.len() as f64
134    };
135    let mut outliers: Vec<SizeOutlier> = inventory
136        .iter()
137        .filter(|(_, size, _)| *size >= 1_048_576 && *size as f64 >= 10.0 * mean)
138        .map(|(path, size, format)| SizeOutlier {
139            path: path.clone(),
140            bytes: *size,
141            format: format.clone(),
142        })
143        .collect();
144    outliers.sort_by(|a, b| b.bytes.cmp(&a.bytes).then(a.path.cmp(&b.path)));
145
146    Ok(ScanReport {
147        root: root.display().to_string(),
148        rules_version: rules.version().to_string(),
149        files: inventory.len() as u64,
150        bytes: total_bytes,
151        formats: by_format
152            .into_iter()
153            .map(|(format, (files, bytes))| FormatCount {
154                format,
155                files,
156                bytes,
157            })
158            .collect(),
159        outliers,
160    })
161}
162
163pub(crate) fn validate_division(name: &str) -> Result<()> {
164    let usable = !name.is_empty()
165        && name
166            .chars()
167            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
168    if usable {
169        Ok(())
170    } else {
171        Err(Error::Division {
172            name: name.to_string(),
173        })
174    }
175}
176
177/// Resolves a path for containment checks without creating anything.
178///
179/// Existing prefixes canonicalize, so symlinks resolve. Components
180/// past the first missing one cannot be symlinks, so they resolve
181/// lexically, and a parent component pops a base that is already
182/// symlink-free.
183fn resolve_for_check(path: &Path) -> Result<PathBuf> {
184    use std::path::Component;
185    let absolute = std::path::absolute(path).map_err(|e| Error::io("absolutize", path, e))?;
186    let mut resolved = PathBuf::new();
187    for component in absolute.components() {
188        match component {
189            Component::Prefix(_) | Component::RootDir => resolved.push(component),
190            Component::CurDir => {}
191            Component::ParentDir => {
192                resolved.pop();
193            }
194            Component::Normal(name) => {
195                resolved.push(name);
196                if let Ok(canonical) = resolved.canonicalize() {
197                    resolved = canonical;
198                }
199            }
200        }
201    }
202    Ok(resolved)
203}
204
205/// Refuses a layout that nests the division root and an output root
206/// either way, so outputs can never become next-run sources and
207/// artifacts can never land inside the source tree.
208///
209/// Every path resolves without touching the filesystem state, and the
210/// output directories are created only after the layout is approved,
211/// so a refusal leaves the division root untouched.
212fn check_layout(root: &Path, mirror_root: &Path, manifest_dir: &Path) -> Result<()> {
213    let root_resolved = resolve_for_check(root)?;
214    for (name, dir) in [
215        ("mirror root", mirror_root),
216        ("manifest directory", manifest_dir),
217    ] {
218        let dir_resolved = resolve_for_check(dir)?;
219        if dir_resolved.starts_with(&root_resolved) {
220            return Err(Error::Layout {
221                message: format!(
222                    "{name} {} lies inside the division root {}",
223                    dir.display(),
224                    root.display()
225                ),
226            });
227        }
228        if root_resolved.starts_with(&dir_resolved) {
229            return Err(Error::Layout {
230                message: format!(
231                    "division root {} lies inside the {name} {}",
232                    root.display(),
233                    dir.display()
234                ),
235            });
236        }
237    }
238    fs::create_dir_all(mirror_root).map_err(|e| Error::io("create_dir", mirror_root, e))?;
239    fs::create_dir_all(manifest_dir).map_err(|e| Error::io("create_dir", manifest_dir, e))?;
240    Ok(())
241}
242
243fn new_record(
244    source_path: &str,
245    source_hash: &str,
246    source_size: u64,
247    detection: &Detection,
248    rules_version: &str,
249    status: Status,
250) -> Record {
251    Record {
252        schema: ManifestSchema,
253        source_path: source_path.to_string(),
254        source_hash: source_hash.to_string(),
255        source_size,
256        declared_format: detection.declared.clone(),
257        detected_format: detection.detected.clone(),
258        format_mismatch: detection.mismatch,
259        status,
260        text_path: None,
261        text_hash: None,
262        artifact_kind: None,
263        converter_id: None,
264        converter_version: None,
265        tool_version: None,
266        rules_version: rules_version.to_string(),
267        media: None,
268        parent_source: None,
269        dedup_of: None,
270        warnings: Vec::new(),
271        error: None,
272        duration_ms: None,
273    }
274}
275
276fn elapsed_ms(started: Instant) -> u64 {
277    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
278}
279
280fn terminal_by_path(records: Vec<Record>) -> HashMap<String, Record> {
281    let mut terminal = HashMap::new();
282    for record in records {
283        terminal.insert(record.source_path.clone(), record);
284    }
285    terminal
286}
287
288/// Seeds the dedup index from prior terminal records.
289///
290/// Only records whose converter version and rules version match the
291/// current run are eligible, so a rules bump never propagates stale
292/// artifacts. Candidates are visited sorted by source path, and the
293/// first insert per hash wins, so the canonical choice is
294/// deterministic.
295fn seed_dedup(terminal: &HashMap<String, Record>, rules: &Rules, dedup: &mut DedupIndex) {
296    let mut candidates: Vec<&Record> = terminal.values().collect();
297    candidates.sort_by(|a, b| a.source_path.cmp(&b.source_path));
298    for record in candidates {
299        let canonical_status = record.status == Status::Converted
300            || (record.status == Status::SkippedUnchanged && record.dedup_of.is_none());
301        if !canonical_status || record.rules_version != rules.version() {
302            continue;
303        }
304        let (Some(text_path), Some(text_hash), Some(converter_id), Some(converter_version)) = (
305            &record.text_path,
306            &record.text_hash,
307            &record.converter_id,
308            &record.converter_version,
309        ) else {
310            continue;
311        };
312        let current = rules.registry.converter_for(&record.detected_format);
313        let versions_match =
314            current.is_some_and(|c| c.id() == converter_id && c.version() == converter_version);
315        if !versions_match {
316            continue;
317        }
318        dedup.insert(
319            &record.source_hash,
320            CanonicalArtifact {
321                source_path: record.source_path.clone(),
322                text_path: text_path.clone(),
323                text_hash: text_hash.clone(),
324                converter_id: converter_id.clone(),
325                converter_version: converter_version.clone(),
326                artifact_kind: record.artifact_kind,
327            },
328        );
329    }
330}
331
332fn record_outcome(
333    writer: &mut ManifestWriter,
334    terminal: &mut HashMap<String, Record>,
335    counts: &mut StatusCounts,
336    record: Record,
337) -> Result<()> {
338    writer.append(&record)?;
339    match record.status {
340        Status::Converted => counts.converted += 1,
341        Status::Failed => counts.failed += 1,
342        Status::Unsupported => counts.unsupported += 1,
343        Status::SkippedUnchanged => counts.skipped_unchanged += 1,
344        Status::Dedup => counts.dedup += 1,
345    }
346    terminal.insert(record.source_path.clone(), record);
347    Ok(())
348}
349
350/// Removes any artifact and segments file left behind for a source
351/// whose terminal outcome carries no text, so the mirror never
352/// contradicts the manifest.
353fn remove_stale_artifact(text_absolute: &Path, segments_absolute: &Path, record: &mut Record) {
354    for stale in [text_absolute, segments_absolute] {
355        match fs::remove_file(stale) {
356            Ok(()) => {}
357            Err(e)
358                if e.kind() == std::io::ErrorKind::NotFound
359                    || e.kind() == std::io::ErrorKind::NotADirectory => {}
360            Err(e) => record
361                .warnings
362                .push(format!("stale_artifact_not_removed: {e}")),
363        }
364    }
365}
366
367/// True when an artifact and its sidecar still match the recorded
368/// hash: the text bytes hash to `text_hash` and the sidecar parses as
369/// segments@1 and validates against the text.
370fn envelope_intact(artifact: &Path, text_hash: &str, segments_path: &Path) -> bool {
371    let Ok(text) = fs::read_to_string(artifact) else {
372        return false;
373    };
374    if hash::hash_bytes(text.as_bytes()) != text_hash {
375        return false;
376    }
377    let Ok(sidecar) = fs::read_to_string(segments_path) else {
378        return false;
379    };
380    segments::parse_jsonl(&sidecar).is_ok_and(|segs| segments::validate(&segs, &text).is_ok())
381}
382
383fn source_front(
384    absolute: &Path,
385    table: &FormatTable,
386) -> Result<(String, u64, Detection, Vec<String>)> {
387    let source_hash = hash::hash_file(absolute)?;
388    let source_size = absolute
389        .metadata()
390        .map_err(|e| Error::io("stat", absolute, e))?
391        .len();
392    let (detection, warnings) = detect::detect_file_with_warnings(absolute, table)?;
393    Ok((source_hash, source_size, detection, warnings))
394}
395
396/// Converts a division root into the mirror tree.
397///
398/// Walks the root in sorted order, detects each file, and appends one
399/// record per source to the division shard. Text-native formats pass
400/// through the registered converter. A format no converter claims
401/// becomes `unsupported`. A converter error becomes `failed` with a
402/// reason and no text artifact. Safe to kill and re-run.
403///
404/// Dedup applies when a source's hash matches an artifact already
405/// converted by the same converter id and version that would handle
406/// this source. The duplicate gets a real text file at its parallel
407/// path, hashed over the bytes actually written, with `dedup_of`
408/// naming the canonical source. When the canonical artifact cannot be
409/// read, the duplicate converts for itself.
410pub fn run(rules: &Rules, options: &RunOptions) -> Result<RunReport> {
411    validate_division(options.division)?;
412    check_layout(options.root, options.mirror_root, options.manifest_dir)?;
413    let entries = walk::walk_division(options.root, &options.walk)?;
414    let shard_path = options
415        .manifest_dir
416        .join(format!("{}.jsonl", options.division));
417    let (prior, mut warnings) = if shard_path.exists() {
418        let shard = manifest::read_shard(&shard_path)?;
419        (shard.records, shard.warnings)
420    } else {
421        (Vec::new(), Vec::new())
422    };
423    let mut terminal = terminal_by_path(prior);
424    let mut dedup = DedupIndex::default();
425    seed_dedup(&terminal, rules, &mut dedup);
426
427    let mut writer = ManifestWriter::open(&shard_path)?;
428    let mut counts = StatusCounts::default();
429    let mut special_entries = 0u64;
430
431    for entry in &entries {
432        let started = Instant::now();
433        let relative = &entry.path;
434        let absolute = options.root.join(relative);
435        let text_absolute = mirror::mirror_path(options.mirror_root, options.division, relative);
436        let segments_absolute =
437            segments::segments_path(options.mirror_root, options.division, relative);
438
439        if entry.kind == EntryKind::Other {
440            special_entries += 1;
441            continue;
442        }
443
444        // A lossy path must never name a record key or an artifact, so
445        // a non-UTF-8 source name, symlink or file, is a recorded
446        // failure with no conversion.
447        let Some(source_path) = relative.to_str().map(str::to_string) else {
448            let detection = Detection {
449                declared: detect::declared_format(relative, &rules.table),
450                detected: UNKNOWN_FORMAT.to_string(),
451                mismatch: false,
452            };
453            let mut record = new_record(
454                &relative.to_string_lossy(),
455                "",
456                0,
457                &detection,
458                rules.version(),
459                Status::Failed,
460            );
461            record.error = Some("non_utf8_path: source path is not valid UTF-8".to_string());
462            record.duration_ms = Some(elapsed_ms(started));
463            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
464            record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
465            continue;
466        };
467
468        if entry.kind == EntryKind::Symlink {
469            let detection = Detection {
470                declared: None,
471                detected: SYMLINK_FORMAT.to_string(),
472                mismatch: false,
473            };
474            let mut record = match fs::read_link(&absolute) {
475                Ok(target) => {
476                    let target_bytes = target.as_os_str().as_encoded_bytes();
477                    let mut record = new_record(
478                        &source_path,
479                        &hash::hash_bytes(target_bytes),
480                        target_bytes.len() as u64,
481                        &detection,
482                        rules.version(),
483                        Status::Unsupported,
484                    );
485                    // Symlinks are unsupported by pipeline design,
486                    // whatever the registry claims, so the reason is
487                    // set directly instead of looked up.
488                    record.error = Some(convert::NO_CONVERTER_REASON.to_string());
489                    record
490                }
491                Err(e) => {
492                    let mut record = new_record(
493                        &source_path,
494                        "",
495                        0,
496                        &detection,
497                        rules.version(),
498                        Status::Failed,
499                    );
500                    record.error = Some(format!("source_io_error: readlink: {e}"));
501                    record
502                }
503            };
504            record.duration_ms = Some(elapsed_ms(started));
505            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
506            record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
507            continue;
508        }
509
510        let front = match source_front(&absolute, &rules.table) {
511            Ok(front) => front,
512            Err(e) => {
513                let detection = Detection {
514                    declared: detect::declared_format(relative, &rules.table),
515                    detected: UNKNOWN_FORMAT.to_string(),
516                    mismatch: false,
517                };
518                let mut record = new_record(
519                    &source_path,
520                    "",
521                    0,
522                    &detection,
523                    rules.version(),
524                    Status::Failed,
525                );
526                record.error = Some(format!("source_io_error: {e}"));
527                record.duration_ms = Some(elapsed_ms(started));
528                remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
529                record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
530                continue;
531            }
532        };
533        let (source_hash, source_size, detection, detect_warnings) = front;
534        let converter = rules.registry.converter_for(&detection.detected);
535        let current_version = converter.map(|c| c.version());
536
537        // Checkpoint: skip only when the key matches and the artifact
538        // on disk still hashes to the recorded text hash.
539        if let Some(previous) = terminal.get(&source_path) {
540            let key_matches = previous.source_hash == source_hash
541                && previous.rules_version == rules.version()
542                && previous.converter_version.as_deref() == current_version;
543            let has_text = matches!(
544                previous.status,
545                Status::Converted | Status::Dedup | Status::SkippedUnchanged
546            );
547            if key_matches && has_text {
548                let intact = match (&previous.text_path, &previous.text_hash) {
549                    (Some(text_path), Some(text_hash)) => {
550                        mirror::resolve_recorded_path(options.mirror_root, text_path).is_ok_and(
551                            |artifact| envelope_intact(&artifact, text_hash, &segments_absolute),
552                        )
553                    }
554                    _ => false,
555                };
556                if intact {
557                    let mut record = previous.clone();
558                    record.status = Status::SkippedUnchanged;
559                    record.error = None;
560                    record.duration_ms = Some(elapsed_ms(started));
561                    record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
562                    continue;
563                }
564            }
565        }
566
567        let text_relative = mirror::recorded_text_path(options.division, &source_path);
568
569        // Dedup: identical bytes already converted once, by the same
570        // converter that would handle this source.
571        if let Some(canonical) = dedup.get(&source_hash)
572            && canonical.source_path != source_path
573            && converter.is_some_and(|c| {
574                c.id() == canonical.converter_id && c.version() == canonical.converter_version
575            })
576        {
577            let canonical = canonical.clone();
578            let canonical_segments = segments::segments_path(
579                options.mirror_root,
580                options.division,
581                Path::new(&canonical.source_path),
582            );
583            // The canonical pair must still match its record: a
584            // resolvable recorded path, text bytes hashing to the
585            // recorded hash, and a sidecar that validates against
586            // them. Anything tampered, malformed, or unreadable falls
587            // through to a real conversion instead of propagating.
588            if let Ok(canonical_text) =
589                mirror::resolve_recorded_path(options.mirror_root, &canonical.text_path)
590                && let (Ok(text), Ok(segment_lines)) = (
591                    fs::read_to_string(&canonical_text),
592                    fs::read_to_string(&canonical_segments),
593                )
594                && hash::hash_bytes(text.as_bytes()) == canonical.text_hash
595                && segments::parse_jsonl(&segment_lines)
596                    .is_ok_and(|segs| segments::validate(&segs, &text).is_ok())
597            {
598                let mut record = new_record(
599                    &source_path,
600                    &source_hash,
601                    source_size,
602                    &detection,
603                    rules.version(),
604                    Status::Dedup,
605                );
606                record.warnings.clone_from(&detect_warnings);
607                match mirror::write_atomic(&text_absolute, &text)
608                    .and_then(|()| mirror::write_atomic(&segments_absolute, &segment_lines))
609                {
610                    Ok(()) => {
611                        record.text_path = Some(text_relative);
612                        record.text_hash = Some(hash::hash_bytes(text.as_bytes()));
613                        record.artifact_kind = canonical.artifact_kind;
614                        record.converter_id = Some(canonical.converter_id.clone());
615                        record.converter_version = Some(canonical.converter_version.clone());
616                        record.dedup_of = Some(canonical.source_path.clone());
617                    }
618                    Err(e) => {
619                        record.status = Status::Failed;
620                        record.error = Some(format!("mirror_write_error: {e}"));
621                        remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
622                    }
623                }
624                record.duration_ms = Some(elapsed_ms(started));
625                record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
626                continue;
627            }
628        }
629
630        let Some(converter) = converter else {
631            let mut record = new_record(
632                &source_path,
633                &source_hash,
634                source_size,
635                &detection,
636                rules.version(),
637                Status::Unsupported,
638            );
639            record.warnings.clone_from(&detect_warnings);
640            record.error = rules
641                .registry
642                .unsupported_reason(&detection.detected)
643                .map(str::to_string);
644            record.duration_ms = Some(elapsed_ms(started));
645            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
646            record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
647            continue;
648        };
649
650        let mut record = new_record(
651            &source_path,
652            &source_hash,
653            source_size,
654            &detection,
655            rules.version(),
656            Status::Converted,
657        );
658        record.warnings.clone_from(&detect_warnings);
659        // One byte snapshot feeds both the recorded hash and the
660        // converter, so the record describes exactly what converted.
661        // The size ceiling applies before the read, the converter runs
662        // under a panic boundary, and the outcome must pass the output
663        // ceiling and segments validation before anything is written.
664        let outcome = if source_size > convert::MAX_SOURCE_BYTES {
665            Err(format!(
666                "resource_limit: source is {source_size} bytes over the {} byte ceiling",
667                convert::MAX_SOURCE_BYTES
668            ))
669        } else {
670            fs::read(&absolute)
671                .map_err(|e| format!("source_io_error: read: {e}"))
672                .and_then(|bytes| {
673                    if hash::hash_bytes(&bytes) != source_hash {
674                        return Err(
675                            "source_changed_during_run: bytes changed between hash and conversion"
676                                .to_string(),
677                        );
678                    }
679                    let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
680                        converter.convert(&bytes, &detection.detected)
681                    }));
682                    match guarded {
683                        Ok(result) => result.map_err(|e| e.to_string()),
684                        Err(_) => Err("converter_panic: conversion panicked".to_string()),
685                    }
686                })
687                .and_then(|outcome| {
688                    if outcome.text.len() > convert::MAX_OUTPUT_BYTES {
689                        return Err(format!(
690                            "resource_limit: output is {} bytes over the {} byte ceiling",
691                            outcome.text.len(),
692                            convert::MAX_OUTPUT_BYTES
693                        ));
694                    }
695                    segments::validate(&outcome.segments, &outcome.text)
696                        .map_err(|detail| format!("invalid_segments: {detail}"))?;
697                    Ok(outcome)
698                })
699        };
700        match outcome {
701            Ok(outcome) => match segments::to_jsonl(&outcome.segments)
702                .map_err(|e| e.to_string())
703                .and_then(|lines| {
704                    mirror::write_atomic(&text_absolute, &outcome.text)
705                        .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
706                        .map_err(|e| e.to_string())
707                }) {
708                Ok(()) => {
709                    let text_hash = hash::hash_bytes(outcome.text.as_bytes());
710                    record.text_path = Some(text_relative.clone());
711                    record.text_hash = Some(text_hash.clone());
712                    record.artifact_kind = Some(ArtifactKind::Text);
713                    record.converter_id = Some(outcome.converter_id.clone());
714                    record.converter_version = Some(outcome.converter_version.clone());
715                    record.warnings.extend(outcome.warnings);
716                    dedup.replace(
717                        &source_hash,
718                        CanonicalArtifact {
719                            source_path: source_path.clone(),
720                            text_path: text_relative,
721                            text_hash,
722                            converter_id: outcome.converter_id,
723                            converter_version: outcome.converter_version,
724                            artifact_kind: record.artifact_kind,
725                        },
726                    );
727                }
728                Err(detail) => {
729                    record.status = Status::Failed;
730                    record.converter_id = Some(converter.id().to_string());
731                    record.converter_version = Some(converter.version().to_string());
732                    record.error = Some(format!("mirror_write_error: {detail}"));
733                    remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
734                }
735            },
736            Err(reason) => {
737                record.status = Status::Failed;
738                record.converter_id = Some(converter.id().to_string());
739                record.converter_version = Some(converter.version().to_string());
740                record.error = Some(reason);
741                remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
742            }
743        }
744        record.duration_ms = Some(elapsed_ms(started));
745        record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
746    }
747
748    warnings.sort();
749    Ok(RunReport {
750        division: options.division.to_string(),
751        root: options.root.display().to_string(),
752        sources: entries.len() as u64 - special_entries,
753        special_entries,
754        counts,
755        warnings,
756    })
757}
758
759fn shard_paths(manifest_dir: &Path) -> Result<Vec<PathBuf>> {
760    let entries = fs::read_dir(manifest_dir).map_err(|e| Error::io("read_dir", manifest_dir, e))?;
761    let mut shards = Vec::new();
762    for entry in entries {
763        let entry = entry.map_err(|e| Error::io("read_dir", manifest_dir, e))?;
764        let path = entry.path();
765        if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
766            shards.push(path);
767        }
768    }
769    shards.sort();
770    Ok(shards)
771}
772
773fn division_name(shard: &Path) -> String {
774    shard
775        .file_stem()
776        .map(|stem| stem.to_string_lossy().into_owned())
777        .unwrap_or_default()
778}
779
780/// Computes coverage per division over terminal records.
781pub fn status(manifest_dir: &Path) -> Result<StatusReport> {
782    let mut divisions = Vec::new();
783    for shard_path in shard_paths(manifest_dir)? {
784        let shard = manifest::read_shard(&shard_path)?;
785        let terminal = terminal_by_path(shard.records);
786        let mut counts = StatusCounts::default();
787        let mut with_text = 0u64;
788        for record in terminal.values() {
789            match record.status {
790                Status::Converted => counts.converted += 1,
791                Status::Failed => counts.failed += 1,
792                Status::Unsupported => counts.unsupported += 1,
793                Status::SkippedUnchanged => counts.skipped_unchanged += 1,
794                Status::Dedup => counts.dedup += 1,
795            }
796            if record.text_path.is_some() {
797                with_text += 1;
798            }
799        }
800        let sources = terminal.len() as u64;
801        let coverage = if sources == 0 {
802            0.0
803        } else {
804            with_text as f64 / sources as f64
805        };
806        divisions.push(DivisionStatus {
807            division: division_name(&shard_path),
808            sources,
809            with_text,
810            coverage,
811            counts,
812            warnings: shard.warnings,
813        });
814    }
815    Ok(StatusReport { divisions })
816}
817
818/// Collects every record for one source path across all shards.
819pub fn explain(manifest_dir: &Path, source_path: &str) -> Result<Vec<ExplainEntry>> {
820    let mut entries = Vec::new();
821    for shard_path in shard_paths(manifest_dir)? {
822        let division = division_name(&shard_path);
823        for record in manifest::read_shard(&shard_path)?.records {
824            if record.source_path == source_path {
825                entries.push(ExplainEntry {
826                    division: division.clone(),
827                    record,
828                });
829            }
830        }
831    }
832    Ok(entries)
833}
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838
839    #[test]
840    fn builtin_rules_agree_on_a_version() {
841        let rules = Rules::builtin().unwrap();
842        assert_eq!(rules.version(), "4");
843    }
844
845    use crate::convert::{ConvertError, Converter, Outcome, PlainTextPassthrough};
846    use crate::segments::Segment;
847
848    /// A converter that panics, for the boundary test. No shipped
849    /// converter panics on purpose, so the test registry injects one.
850    struct PanicConverter;
851
852    impl Converter for PanicConverter {
853        fn id(&self) -> &'static str {
854            "panic-test"
855        }
856        fn version(&self) -> &'static str {
857            "1.0.0"
858        }
859        fn convert(
860            &self,
861            _source: &[u8],
862            _detected_format: &str,
863        ) -> std::result::Result<Outcome, ConvertError> {
864            panic!("deliberate test panic");
865        }
866    }
867
868    /// A converter whose segments do not fit its text.
869    struct BadSegmentsConverter;
870
871    impl Converter for BadSegmentsConverter {
872        fn id(&self) -> &'static str {
873            "bad-segments-test"
874        }
875        fn version(&self) -> &'static str {
876            "1.0.0"
877        }
878        fn convert(
879            &self,
880            _source: &[u8],
881            detected_format: &str,
882        ) -> std::result::Result<Outcome, ConvertError> {
883            Ok(Outcome {
884                converter_id: self.id().to_string(),
885                converter_version: self.version().to_string(),
886                detected_format: detected_format.to_string(),
887                text: "ok".to_string(),
888                warnings: Vec::new(),
889                segments: vec![Segment::span(0, 999, "document")],
890            })
891        }
892    }
893
894    fn injected_rules() -> Rules {
895        let table = FormatTable::parse(
896            r#"
897version = "9"
898
899[[formats]]
900id = "text"
901name = "Plain text"
902extensions = ["txt"]
903
904[[formats]]
905id = "panicfmt"
906name = "Panic trigger"
907extensions = ["panicfmt"]
908
909[[formats]]
910id = "badseg"
911name = "Bad segments trigger"
912extensions = ["badseg"]
913"#,
914            "formats.toml",
915        )
916        .unwrap();
917        let registry = Registry::for_tests(
918            "9",
919            vec![
920                (Box::new(PlainTextPassthrough), vec!["text"]),
921                (Box::new(PanicConverter), vec!["panicfmt"]),
922                (Box::new(BadSegmentsConverter), vec!["badseg"]),
923            ],
924        );
925        Rules { table, registry }
926    }
927
928    #[test]
929    fn a_panicking_converter_fails_one_source_and_the_division_survives() {
930        let dir = tempfile::tempdir().unwrap();
931        let root = dir.path().join("src");
932        fs::create_dir_all(&root).unwrap();
933        fs::write(root.join("boom.panicfmt"), "trigger\n").unwrap();
934        fs::write(root.join("fine.txt"), "safe content\n").unwrap();
935
936        let rules = injected_rules();
937        let report = run(
938            &rules,
939            &RunOptions {
940                root: &root,
941                mirror_root: &dir.path().join("mirror"),
942                manifest_dir: &dir.path().join("manifest"),
943                division: "unit",
944                walk: WalkOptions::default(),
945            },
946        )
947        .unwrap();
948
949        assert_eq!(report.counts.failed, 1);
950        assert_eq!(report.counts.converted, 1);
951        let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
952            .unwrap()
953            .records;
954        let failed = records.iter().find(|r| r.status == Status::Failed).unwrap();
955        assert_eq!(failed.source_path, "boom.panicfmt");
956        assert!(
957            failed
958                .error
959                .as_deref()
960                .unwrap()
961                .starts_with("converter_panic")
962        );
963        assert!(!dir.path().join("mirror/unit/boom.panicfmt.txt").exists());
964    }
965
966    #[test]
967    fn invalid_segments_from_a_converter_fail_before_any_write() {
968        let dir = tempfile::tempdir().unwrap();
969        let root = dir.path().join("src");
970        fs::create_dir_all(&root).unwrap();
971        fs::write(root.join("bad.badseg"), "trigger\n").unwrap();
972
973        let rules = injected_rules();
974        let report = run(
975            &rules,
976            &RunOptions {
977                root: &root,
978                mirror_root: &dir.path().join("mirror"),
979                manifest_dir: &dir.path().join("manifest"),
980                division: "unit",
981                walk: WalkOptions::default(),
982            },
983        )
984        .unwrap();
985
986        assert_eq!(report.counts.failed, 1);
987        let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
988            .unwrap()
989            .records;
990        assert!(
991            records[0]
992                .error
993                .as_deref()
994                .unwrap()
995                .starts_with("invalid_segments")
996        );
997        assert!(!dir.path().join("mirror/unit/bad.badseg.txt").exists());
998        assert!(
999            !dir.path()
1000                .join("mirror/unit/bad.badseg.segments.jsonl")
1001                .exists()
1002        );
1003    }
1004
1005    #[test]
1006    fn division_names_are_validated() {
1007        assert!(validate_division("finance-eu_1").is_ok());
1008        assert!(validate_division("").is_err());
1009        assert!(validate_division("a/b").is_err());
1010        assert!(validate_division("dot.dot").is_err());
1011    }
1012
1013    #[test]
1014    fn layout_rejects_outputs_inside_the_root() {
1015        let dir = tempfile::tempdir().unwrap();
1016        let root = dir.path().join("src");
1017        fs::create_dir_all(&root).unwrap();
1018        let outside = dir.path().join("out");
1019
1020        let err = check_layout(&root, &root.join("mirror"), &outside).unwrap_err();
1021        assert!(err.to_string().contains("mirror root"));
1022
1023        let err = check_layout(&root, &outside.join("mirror"), &root.join("manifest")).unwrap_err();
1024        assert!(err.to_string().contains("manifest directory"));
1025
1026        check_layout(&root, &outside.join("mirror"), &outside.join("manifest")).unwrap();
1027    }
1028
1029    #[cfg(unix)]
1030    #[test]
1031    fn non_utf8_paths_are_rejected_without_an_artifact() {
1032        use std::os::unix::ffi::OsStrExt;
1033        let raw = std::ffi::OsStr::from_bytes(b"bad-\xff-name.txt");
1034        assert!(Path::new(raw).to_str().is_none());
1035    }
1036
1037    #[test]
1038    fn identical_sources_dedup_to_one_conversion() {
1039        let dir = tempfile::tempdir().unwrap();
1040        let root = dir.path().join("src");
1041        fs::create_dir_all(root.join("sub")).unwrap();
1042        fs::write(root.join("first.txt"), "same bytes\n").unwrap();
1043        fs::write(root.join("sub/second.txt"), "same bytes\n").unwrap();
1044
1045        let rules = Rules::builtin().unwrap();
1046        let report = run(
1047            &rules,
1048            &RunOptions {
1049                root: &root,
1050                mirror_root: &dir.path().join("mirror"),
1051                manifest_dir: &dir.path().join("manifest"),
1052                division: "unit",
1053                walk: WalkOptions::default(),
1054            },
1055        )
1056        .unwrap();
1057
1058        assert_eq!(report.counts.converted, 1);
1059        assert_eq!(report.counts.dedup, 1);
1060
1061        let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
1062            .unwrap()
1063            .records;
1064        let canonical = records
1065            .iter()
1066            .find(|r| r.source_path == "first.txt")
1067            .unwrap();
1068        let duplicate = records
1069            .iter()
1070            .find(|r| r.source_path == "sub/second.txt")
1071            .unwrap();
1072        assert_eq!(canonical.status, Status::Converted);
1073        assert_eq!(duplicate.status, Status::Dedup);
1074        assert_eq!(duplicate.dedup_of.as_deref(), Some("first.txt"));
1075        assert_eq!(duplicate.text_hash, canonical.text_hash);
1076
1077        let mirrored =
1078            fs::read_to_string(dir.path().join("mirror/unit/sub/second.txt.txt")).unwrap();
1079        assert_eq!(mirrored, "same bytes\n");
1080    }
1081}