1use 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
35pub const SYMLINK_FORMAT: &str = "symlink";
37
38pub const SPECIAL_FORMAT: &str = "special";
40
41pub struct Rules {
47 pub table: FormatTable,
49 pub registry: Registry,
51}
52
53impl Rules {
54 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 pub fn version(&self) -> &str {
73 self.table.version()
74 }
75}
76
77pub struct RunOptions<'a> {
79 pub root: &'a Path,
81 pub mirror_root: &'a Path,
83 pub manifest_dir: &'a Path,
85 pub division: &'a str,
87 pub walk: WalkOptions,
89}
90
91pub 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
177fn 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
205fn 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
288fn 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
350fn 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
367fn 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
396pub 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 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 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 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 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 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 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
780pub 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
818pub 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 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 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}