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(absolute: &Path, table: &FormatTable) -> Result<(String, u64, Detection)> {
384    let source_hash = hash::hash_file(absolute)?;
385    let source_size = absolute
386        .metadata()
387        .map_err(|e| Error::io("stat", absolute, e))?
388        .len();
389    let detection = detect::detect_file(absolute, table)?;
390    Ok((source_hash, source_size, detection))
391}
392
393/// Converts a division root into the mirror tree.
394///
395/// Walks the root in sorted order, detects each file, and appends one
396/// record per source to the division shard. Text-native formats pass
397/// through the registered converter. A format no converter claims
398/// becomes `unsupported`. A converter error becomes `failed` with a
399/// reason and no text artifact. Safe to kill and re-run.
400///
401/// Dedup applies when a source's hash matches an artifact already
402/// converted by the same converter id and version that would handle
403/// this source. The duplicate gets a real text file at its parallel
404/// path, hashed over the bytes actually written, with `dedup_of`
405/// naming the canonical source. When the canonical artifact cannot be
406/// read, the duplicate converts for itself.
407pub fn run(rules: &Rules, options: &RunOptions) -> Result<RunReport> {
408    validate_division(options.division)?;
409    check_layout(options.root, options.mirror_root, options.manifest_dir)?;
410    let entries = walk::walk_division(options.root, &options.walk)?;
411    let shard_path = options
412        .manifest_dir
413        .join(format!("{}.jsonl", options.division));
414    let (prior, mut warnings) = if shard_path.exists() {
415        let shard = manifest::read_shard(&shard_path)?;
416        (shard.records, shard.warnings)
417    } else {
418        (Vec::new(), Vec::new())
419    };
420    let mut terminal = terminal_by_path(prior);
421    let mut dedup = DedupIndex::default();
422    seed_dedup(&terminal, rules, &mut dedup);
423
424    let mut writer = ManifestWriter::open(&shard_path)?;
425    let mut counts = StatusCounts::default();
426    let mut special_entries = 0u64;
427
428    for entry in &entries {
429        let started = Instant::now();
430        let relative = &entry.path;
431        let absolute = options.root.join(relative);
432        let text_absolute = mirror::mirror_path(options.mirror_root, options.division, relative);
433        let segments_absolute =
434            segments::segments_path(options.mirror_root, options.division, relative);
435
436        if entry.kind == EntryKind::Other {
437            special_entries += 1;
438            continue;
439        }
440
441        // A lossy path must never name a record key or an artifact, so
442        // a non-UTF-8 source name, symlink or file, is a recorded
443        // failure with no conversion.
444        let Some(source_path) = relative.to_str().map(str::to_string) else {
445            let detection = Detection {
446                declared: detect::declared_format(relative, &rules.table),
447                detected: UNKNOWN_FORMAT.to_string(),
448                mismatch: false,
449            };
450            let mut record = new_record(
451                &relative.to_string_lossy(),
452                "",
453                0,
454                &detection,
455                rules.version(),
456                Status::Failed,
457            );
458            record.error = Some("non_utf8_path: source path is not valid UTF-8".to_string());
459            record.duration_ms = Some(elapsed_ms(started));
460            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
461            record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
462            continue;
463        };
464
465        if entry.kind == EntryKind::Symlink {
466            let detection = Detection {
467                declared: None,
468                detected: SYMLINK_FORMAT.to_string(),
469                mismatch: false,
470            };
471            let mut record = match fs::read_link(&absolute) {
472                Ok(target) => {
473                    let target_bytes = target.as_os_str().as_encoded_bytes();
474                    new_record(
475                        &source_path,
476                        &hash::hash_bytes(target_bytes),
477                        target_bytes.len() as u64,
478                        &detection,
479                        rules.version(),
480                        Status::Unsupported,
481                    )
482                }
483                Err(e) => {
484                    let mut record = new_record(
485                        &source_path,
486                        "",
487                        0,
488                        &detection,
489                        rules.version(),
490                        Status::Failed,
491                    );
492                    record.error = Some(format!("source_io_error: readlink: {e}"));
493                    record
494                }
495            };
496            record.duration_ms = Some(elapsed_ms(started));
497            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
498            record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
499            continue;
500        }
501
502        let (source_hash, source_size, detection) = match source_front(&absolute, &rules.table) {
503            Ok(front) => front,
504            Err(e) => {
505                let detection = Detection {
506                    declared: detect::declared_format(relative, &rules.table),
507                    detected: UNKNOWN_FORMAT.to_string(),
508                    mismatch: false,
509                };
510                let mut record = new_record(
511                    &source_path,
512                    "",
513                    0,
514                    &detection,
515                    rules.version(),
516                    Status::Failed,
517                );
518                record.error = Some(format!("source_io_error: {e}"));
519                record.duration_ms = Some(elapsed_ms(started));
520                remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
521                record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
522                continue;
523            }
524        };
525        let converter = rules.registry.converter_for(&detection.detected);
526        let current_version = converter.map(|c| c.version());
527
528        // Checkpoint: skip only when the key matches and the artifact
529        // on disk still hashes to the recorded text hash.
530        if let Some(previous) = terminal.get(&source_path) {
531            let key_matches = previous.source_hash == source_hash
532                && previous.rules_version == rules.version()
533                && previous.converter_version.as_deref() == current_version;
534            let has_text = matches!(
535                previous.status,
536                Status::Converted | Status::Dedup | Status::SkippedUnchanged
537            );
538            if key_matches && has_text {
539                let intact = match (&previous.text_path, &previous.text_hash) {
540                    (Some(text_path), Some(text_hash)) => {
541                        mirror::resolve_recorded_path(options.mirror_root, text_path).is_ok_and(
542                            |artifact| envelope_intact(&artifact, text_hash, &segments_absolute),
543                        )
544                    }
545                    _ => false,
546                };
547                if intact {
548                    let mut record = previous.clone();
549                    record.status = Status::SkippedUnchanged;
550                    record.error = None;
551                    record.duration_ms = Some(elapsed_ms(started));
552                    record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
553                    continue;
554                }
555            }
556        }
557
558        let text_relative = mirror::recorded_text_path(options.division, &source_path);
559
560        // Dedup: identical bytes already converted once, by the same
561        // converter that would handle this source.
562        if let Some(canonical) = dedup.get(&source_hash)
563            && canonical.source_path != source_path
564            && converter.is_some_and(|c| {
565                c.id() == canonical.converter_id && c.version() == canonical.converter_version
566            })
567        {
568            let canonical = canonical.clone();
569            let canonical_segments = segments::segments_path(
570                options.mirror_root,
571                options.division,
572                Path::new(&canonical.source_path),
573            );
574            // The canonical pair must still match its record: a
575            // resolvable recorded path, text bytes hashing to the
576            // recorded hash, and a sidecar that validates against
577            // them. Anything tampered, malformed, or unreadable falls
578            // through to a real conversion instead of propagating.
579            if let Ok(canonical_text) =
580                mirror::resolve_recorded_path(options.mirror_root, &canonical.text_path)
581                && let (Ok(text), Ok(segment_lines)) = (
582                    fs::read_to_string(&canonical_text),
583                    fs::read_to_string(&canonical_segments),
584                )
585                && hash::hash_bytes(text.as_bytes()) == canonical.text_hash
586                && segments::parse_jsonl(&segment_lines)
587                    .is_ok_and(|segs| segments::validate(&segs, &text).is_ok())
588            {
589                let mut record = new_record(
590                    &source_path,
591                    &source_hash,
592                    source_size,
593                    &detection,
594                    rules.version(),
595                    Status::Dedup,
596                );
597                match mirror::write_atomic(&text_absolute, &text)
598                    .and_then(|()| mirror::write_atomic(&segments_absolute, &segment_lines))
599                {
600                    Ok(()) => {
601                        record.text_path = Some(text_relative);
602                        record.text_hash = Some(hash::hash_bytes(text.as_bytes()));
603                        record.artifact_kind = canonical.artifact_kind;
604                        record.converter_id = Some(canonical.converter_id.clone());
605                        record.converter_version = Some(canonical.converter_version.clone());
606                        record.dedup_of = Some(canonical.source_path.clone());
607                    }
608                    Err(e) => {
609                        record.status = Status::Failed;
610                        record.error = Some(format!("mirror_write_error: {e}"));
611                        remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
612                    }
613                }
614                record.duration_ms = Some(elapsed_ms(started));
615                record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
616                continue;
617            }
618        }
619
620        let Some(converter) = converter else {
621            let mut record = new_record(
622                &source_path,
623                &source_hash,
624                source_size,
625                &detection,
626                rules.version(),
627                Status::Unsupported,
628            );
629            record.error = rules
630                .registry
631                .unsupported_reason(&detection.detected)
632                .map(str::to_string);
633            record.duration_ms = Some(elapsed_ms(started));
634            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
635            record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
636            continue;
637        };
638
639        let mut record = new_record(
640            &source_path,
641            &source_hash,
642            source_size,
643            &detection,
644            rules.version(),
645            Status::Converted,
646        );
647        // One byte snapshot feeds both the recorded hash and the
648        // converter, so the record describes exactly what converted.
649        // The size ceiling applies before the read, the converter runs
650        // under a panic boundary, and the outcome must pass the output
651        // ceiling and segments validation before anything is written.
652        let outcome = if source_size > convert::MAX_SOURCE_BYTES {
653            Err(format!(
654                "resource_limit: source is {source_size} bytes over the {} byte ceiling",
655                convert::MAX_SOURCE_BYTES
656            ))
657        } else {
658            fs::read(&absolute)
659                .map_err(|e| format!("source_io_error: read: {e}"))
660                .and_then(|bytes| {
661                    if hash::hash_bytes(&bytes) != source_hash {
662                        return Err(
663                            "source_changed_during_run: bytes changed between hash and conversion"
664                                .to_string(),
665                        );
666                    }
667                    let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
668                        converter.convert(&bytes, &detection.detected)
669                    }));
670                    match guarded {
671                        Ok(result) => result.map_err(|e| e.to_string()),
672                        Err(_) => Err("converter_panic: conversion panicked".to_string()),
673                    }
674                })
675                .and_then(|outcome| {
676                    if outcome.text.len() > convert::MAX_OUTPUT_BYTES {
677                        return Err(format!(
678                            "resource_limit: output is {} bytes over the {} byte ceiling",
679                            outcome.text.len(),
680                            convert::MAX_OUTPUT_BYTES
681                        ));
682                    }
683                    segments::validate(&outcome.segments, &outcome.text)
684                        .map_err(|detail| format!("invalid_segments: {detail}"))?;
685                    Ok(outcome)
686                })
687        };
688        match outcome {
689            Ok(outcome) => match segments::to_jsonl(&outcome.segments)
690                .map_err(|e| e.to_string())
691                .and_then(|lines| {
692                    mirror::write_atomic(&text_absolute, &outcome.text)
693                        .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
694                        .map_err(|e| e.to_string())
695                }) {
696                Ok(()) => {
697                    let text_hash = hash::hash_bytes(outcome.text.as_bytes());
698                    record.text_path = Some(text_relative.clone());
699                    record.text_hash = Some(text_hash.clone());
700                    record.artifact_kind = Some(ArtifactKind::Text);
701                    record.converter_id = Some(outcome.converter_id.clone());
702                    record.converter_version = Some(outcome.converter_version.clone());
703                    record.warnings = outcome.warnings;
704                    dedup.replace(
705                        &source_hash,
706                        CanonicalArtifact {
707                            source_path: source_path.clone(),
708                            text_path: text_relative,
709                            text_hash,
710                            converter_id: outcome.converter_id,
711                            converter_version: outcome.converter_version,
712                            artifact_kind: record.artifact_kind,
713                        },
714                    );
715                }
716                Err(detail) => {
717                    record.status = Status::Failed;
718                    record.converter_id = Some(converter.id().to_string());
719                    record.converter_version = Some(converter.version().to_string());
720                    record.error = Some(format!("mirror_write_error: {detail}"));
721                    remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
722                }
723            },
724            Err(reason) => {
725                record.status = Status::Failed;
726                record.converter_id = Some(converter.id().to_string());
727                record.converter_version = Some(converter.version().to_string());
728                record.error = Some(reason);
729                remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
730            }
731        }
732        record.duration_ms = Some(elapsed_ms(started));
733        record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
734    }
735
736    warnings.sort();
737    Ok(RunReport {
738        division: options.division.to_string(),
739        root: options.root.display().to_string(),
740        sources: entries.len() as u64 - special_entries,
741        special_entries,
742        counts,
743        warnings,
744    })
745}
746
747fn shard_paths(manifest_dir: &Path) -> Result<Vec<PathBuf>> {
748    let entries = fs::read_dir(manifest_dir).map_err(|e| Error::io("read_dir", manifest_dir, e))?;
749    let mut shards = Vec::new();
750    for entry in entries {
751        let entry = entry.map_err(|e| Error::io("read_dir", manifest_dir, e))?;
752        let path = entry.path();
753        if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
754            shards.push(path);
755        }
756    }
757    shards.sort();
758    Ok(shards)
759}
760
761fn division_name(shard: &Path) -> String {
762    shard
763        .file_stem()
764        .map(|stem| stem.to_string_lossy().into_owned())
765        .unwrap_or_default()
766}
767
768/// Computes coverage per division over terminal records.
769pub fn status(manifest_dir: &Path) -> Result<StatusReport> {
770    let mut divisions = Vec::new();
771    for shard_path in shard_paths(manifest_dir)? {
772        let shard = manifest::read_shard(&shard_path)?;
773        let terminal = terminal_by_path(shard.records);
774        let mut counts = StatusCounts::default();
775        let mut with_text = 0u64;
776        for record in terminal.values() {
777            match record.status {
778                Status::Converted => counts.converted += 1,
779                Status::Failed => counts.failed += 1,
780                Status::Unsupported => counts.unsupported += 1,
781                Status::SkippedUnchanged => counts.skipped_unchanged += 1,
782                Status::Dedup => counts.dedup += 1,
783            }
784            if record.text_path.is_some() {
785                with_text += 1;
786            }
787        }
788        let sources = terminal.len() as u64;
789        let coverage = if sources == 0 {
790            0.0
791        } else {
792            with_text as f64 / sources as f64
793        };
794        divisions.push(DivisionStatus {
795            division: division_name(&shard_path),
796            sources,
797            with_text,
798            coverage,
799            counts,
800            warnings: shard.warnings,
801        });
802    }
803    Ok(StatusReport { divisions })
804}
805
806/// Collects every record for one source path across all shards.
807pub fn explain(manifest_dir: &Path, source_path: &str) -> Result<Vec<ExplainEntry>> {
808    let mut entries = Vec::new();
809    for shard_path in shard_paths(manifest_dir)? {
810        let division = division_name(&shard_path);
811        for record in manifest::read_shard(&shard_path)?.records {
812            if record.source_path == source_path {
813                entries.push(ExplainEntry {
814                    division: division.clone(),
815                    record,
816                });
817            }
818        }
819    }
820    Ok(entries)
821}
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826
827    #[test]
828    fn builtin_rules_agree_on_a_version() {
829        let rules = Rules::builtin().unwrap();
830        assert_eq!(rules.version(), "3");
831    }
832
833    use crate::convert::{ConvertError, Converter, Outcome, PlainTextPassthrough};
834    use crate::segments::Segment;
835
836    /// A converter that panics, for the boundary test. No shipped
837    /// converter panics on purpose, so the test registry injects one.
838    struct PanicConverter;
839
840    impl Converter for PanicConverter {
841        fn id(&self) -> &'static str {
842            "panic-test"
843        }
844        fn version(&self) -> &'static str {
845            "1.0.0"
846        }
847        fn convert(
848            &self,
849            _source: &[u8],
850            _detected_format: &str,
851        ) -> std::result::Result<Outcome, ConvertError> {
852            panic!("deliberate test panic");
853        }
854    }
855
856    /// A converter whose segments do not fit its text.
857    struct BadSegmentsConverter;
858
859    impl Converter for BadSegmentsConverter {
860        fn id(&self) -> &'static str {
861            "bad-segments-test"
862        }
863        fn version(&self) -> &'static str {
864            "1.0.0"
865        }
866        fn convert(
867            &self,
868            _source: &[u8],
869            detected_format: &str,
870        ) -> std::result::Result<Outcome, ConvertError> {
871            Ok(Outcome {
872                converter_id: self.id().to_string(),
873                converter_version: self.version().to_string(),
874                detected_format: detected_format.to_string(),
875                text: "ok".to_string(),
876                warnings: Vec::new(),
877                segments: vec![Segment::span(0, 999, "document")],
878            })
879        }
880    }
881
882    fn injected_rules() -> Rules {
883        let table = FormatTable::parse(
884            r#"
885version = "9"
886
887[[formats]]
888id = "text"
889name = "Plain text"
890extensions = ["txt"]
891
892[[formats]]
893id = "panicfmt"
894name = "Panic trigger"
895extensions = ["panicfmt"]
896
897[[formats]]
898id = "badseg"
899name = "Bad segments trigger"
900extensions = ["badseg"]
901"#,
902            "formats.toml",
903        )
904        .unwrap();
905        let registry = Registry::for_tests(
906            "9",
907            vec![
908                (Box::new(PlainTextPassthrough), vec!["text"]),
909                (Box::new(PanicConverter), vec!["panicfmt"]),
910                (Box::new(BadSegmentsConverter), vec!["badseg"]),
911            ],
912        );
913        Rules { table, registry }
914    }
915
916    #[test]
917    fn a_panicking_converter_fails_one_source_and_the_division_survives() {
918        let dir = tempfile::tempdir().unwrap();
919        let root = dir.path().join("src");
920        fs::create_dir_all(&root).unwrap();
921        fs::write(root.join("boom.panicfmt"), "trigger\n").unwrap();
922        fs::write(root.join("fine.txt"), "safe content\n").unwrap();
923
924        let rules = injected_rules();
925        let report = run(
926            &rules,
927            &RunOptions {
928                root: &root,
929                mirror_root: &dir.path().join("mirror"),
930                manifest_dir: &dir.path().join("manifest"),
931                division: "unit",
932                walk: WalkOptions::default(),
933            },
934        )
935        .unwrap();
936
937        assert_eq!(report.counts.failed, 1);
938        assert_eq!(report.counts.converted, 1);
939        let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
940            .unwrap()
941            .records;
942        let failed = records.iter().find(|r| r.status == Status::Failed).unwrap();
943        assert_eq!(failed.source_path, "boom.panicfmt");
944        assert!(
945            failed
946                .error
947                .as_deref()
948                .unwrap()
949                .starts_with("converter_panic")
950        );
951        assert!(!dir.path().join("mirror/unit/boom.panicfmt.txt").exists());
952    }
953
954    #[test]
955    fn invalid_segments_from_a_converter_fail_before_any_write() {
956        let dir = tempfile::tempdir().unwrap();
957        let root = dir.path().join("src");
958        fs::create_dir_all(&root).unwrap();
959        fs::write(root.join("bad.badseg"), "trigger\n").unwrap();
960
961        let rules = injected_rules();
962        let report = run(
963            &rules,
964            &RunOptions {
965                root: &root,
966                mirror_root: &dir.path().join("mirror"),
967                manifest_dir: &dir.path().join("manifest"),
968                division: "unit",
969                walk: WalkOptions::default(),
970            },
971        )
972        .unwrap();
973
974        assert_eq!(report.counts.failed, 1);
975        let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
976            .unwrap()
977            .records;
978        assert!(
979            records[0]
980                .error
981                .as_deref()
982                .unwrap()
983                .starts_with("invalid_segments")
984        );
985        assert!(!dir.path().join("mirror/unit/bad.badseg.txt").exists());
986        assert!(
987            !dir.path()
988                .join("mirror/unit/bad.badseg.segments.jsonl")
989                .exists()
990        );
991    }
992
993    #[test]
994    fn division_names_are_validated() {
995        assert!(validate_division("finance-eu_1").is_ok());
996        assert!(validate_division("").is_err());
997        assert!(validate_division("a/b").is_err());
998        assert!(validate_division("dot.dot").is_err());
999    }
1000
1001    #[test]
1002    fn layout_rejects_outputs_inside_the_root() {
1003        let dir = tempfile::tempdir().unwrap();
1004        let root = dir.path().join("src");
1005        fs::create_dir_all(&root).unwrap();
1006        let outside = dir.path().join("out");
1007
1008        let err = check_layout(&root, &root.join("mirror"), &outside).unwrap_err();
1009        assert!(err.to_string().contains("mirror root"));
1010
1011        let err = check_layout(&root, &outside.join("mirror"), &root.join("manifest")).unwrap_err();
1012        assert!(err.to_string().contains("manifest directory"));
1013
1014        check_layout(&root, &outside.join("mirror"), &outside.join("manifest")).unwrap();
1015    }
1016
1017    #[cfg(unix)]
1018    #[test]
1019    fn non_utf8_paths_are_rejected_without_an_artifact() {
1020        use std::os::unix::ffi::OsStrExt;
1021        let raw = std::ffi::OsStr::from_bytes(b"bad-\xff-name.txt");
1022        assert!(Path::new(raw).to_str().is_none());
1023    }
1024
1025    #[test]
1026    fn identical_sources_dedup_to_one_conversion() {
1027        let dir = tempfile::tempdir().unwrap();
1028        let root = dir.path().join("src");
1029        fs::create_dir_all(root.join("sub")).unwrap();
1030        fs::write(root.join("first.txt"), "same bytes\n").unwrap();
1031        fs::write(root.join("sub/second.txt"), "same bytes\n").unwrap();
1032
1033        let rules = Rules::builtin().unwrap();
1034        let report = run(
1035            &rules,
1036            &RunOptions {
1037                root: &root,
1038                mirror_root: &dir.path().join("mirror"),
1039                manifest_dir: &dir.path().join("manifest"),
1040                division: "unit",
1041                walk: WalkOptions::default(),
1042            },
1043        )
1044        .unwrap();
1045
1046        assert_eq!(report.counts.converted, 1);
1047        assert_eq!(report.counts.dedup, 1);
1048
1049        let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
1050            .unwrap()
1051            .records;
1052        let canonical = records
1053            .iter()
1054            .find(|r| r.source_path == "first.txt")
1055            .unwrap();
1056        let duplicate = records
1057            .iter()
1058            .find(|r| r.source_path == "sub/second.txt")
1059            .unwrap();
1060        assert_eq!(canonical.status, Status::Converted);
1061        assert_eq!(duplicate.status, Status::Dedup);
1062        assert_eq!(duplicate.dedup_of.as_deref(), Some("first.txt"));
1063        assert_eq!(duplicate.text_hash, canonical.text_hash);
1064
1065        let mirrored =
1066            fs::read_to_string(dir.path().join("mirror/unit/sub/second.txt.txt")).unwrap();
1067        assert_eq!(mirrored, "same bytes\n");
1068    }
1069}