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(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
393pub 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 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 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 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 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 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
768pub 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
806pub 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 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 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}