1use std::collections::{BTreeMap, BTreeSet, HashMap};
22use std::fs;
23use std::path::{Path, PathBuf};
24
25use serde::de::Error as _;
26use serde::{Deserialize, Deserializer, Serialize, Serializer};
27
28use crate::manifest::{self, ManifestSchema, Record, Status};
29use crate::{Error, Result, hash};
30
31pub const BUNDLE_SCHEMA: &str = "text-mirror/bundle@1";
33
34pub const CHECKSUMS_NAME: &str = "checksums.b3";
36
37pub const DESCRIPTOR_NAME: &str = "BUNDLE.json";
39
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub struct BundleSchema;
45
46impl Serialize for BundleSchema {
47 fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
48 serializer.serialize_str(BUNDLE_SCHEMA)
49 }
50}
51
52impl<'de> Deserialize<'de> for BundleSchema {
53 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
54 let value = String::deserialize(deserializer)?;
55 if value == BUNDLE_SCHEMA {
56 Ok(BundleSchema)
57 } else {
58 Err(D::Error::custom(format!(
59 "unrecognized schema {value:?}, this consumer accepts only {BUNDLE_SCHEMA:?}"
60 )))
61 }
62 }
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct DivisionCounts {
70 pub converted: u64,
72 pub failed: u64,
74 pub unsupported: u64,
76 pub skipped_unchanged: u64,
78 pub dedup: u64,
80 pub total: u64,
82}
83
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct DivisionCoverage {
88 pub covered: u64,
92 pub total: u64,
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct BundleDescriptor {
104 pub schema: BundleSchema,
106 pub manifest_schema: ManifestSchema,
108 pub run_ids: Vec<String>,
114 pub divisions: Vec<String>,
116 pub counts: BTreeMap<String, DivisionCounts>,
119 pub coverage: BTreeMap<String, DivisionCoverage>,
122 pub checksums_hash: String,
126}
127
128pub fn valid_run_id(id: &str) -> bool {
132 let bytes = id.as_bytes();
133 if bytes.is_empty() || bytes.len() > 64 || !bytes[0].is_ascii_alphanumeric() {
134 return false;
135 }
136 bytes[1..]
137 .iter()
138 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
139}
140
141fn collision_key(text_path: &str) -> String {
146 use unicode_normalization::UnicodeNormalization;
147 text_path.nfc().collect::<String>().to_ascii_lowercase()
148}
149
150fn refused(verb: &'static str, problems: Vec<String>) -> Error {
151 Error::Refused { verb, problems }
152}
153
154fn rules_file_version(text: &str, name: &str) -> std::result::Result<String, String> {
157 #[derive(Deserialize)]
158 struct VersionOnly {
159 version: String,
160 }
161 toml::from_str::<VersionOnly>(text)
162 .map(|v| v.version)
163 .map_err(|e| format!("{name}: cannot read the declared rules version: {e}"))
164}
165
166fn enumerate_entries(root: &Path) -> Result<Vec<(String, bool)>> {
170 fn visit(dir: &Path, base: &Path, out: &mut Vec<(String, bool)>) -> Result<()> {
171 let entries = fs::read_dir(dir).map_err(|e| Error::io("read_dir", dir, e))?;
172 for entry in entries {
173 let entry = entry.map_err(|e| Error::io("read_dir", dir, e))?;
174 let path = entry.path();
175 let file_type = entry.file_type().map_err(|e| Error::io("stat", &path, e))?;
176 let relative = path
177 .strip_prefix(base)
178 .expect("entries live under the base")
179 .components()
180 .map(|c| c.as_os_str().to_string_lossy())
181 .collect::<Vec<_>>()
182 .join("/");
183 if file_type.is_dir() {
184 out.push((relative, true));
185 visit(&path, base, out)?;
186 } else if file_type.is_file() {
187 out.push((relative, false));
188 } else {
189 return Err(Error::io(
190 "enumerate",
191 &path,
192 std::io::Error::new(
193 std::io::ErrorKind::InvalidInput,
194 "bundle trees hold regular files only",
195 ),
196 ));
197 }
198 }
199 Ok(())
200 }
201 let mut entries = Vec::new();
202 visit(root, root, &mut entries)?;
203 entries.sort();
204 Ok(entries)
205}
206
207fn effective_records(records: &[Record]) -> BTreeMap<String, &Record> {
213 let mut effective = BTreeMap::new();
214 for record in records {
215 effective.insert(record.source_path.clone(), record);
216 }
217 effective
218}
219
220fn covered_status(status: Status) -> bool {
221 matches!(
222 status,
223 Status::Converted | Status::Dedup | Status::SkippedUnchanged
224 )
225}
226
227fn tally_counts(records: &[Record]) -> DivisionCounts {
230 let mut counts = DivisionCounts::default();
231 for record in records {
232 match record.status {
233 Status::Converted => counts.converted += 1,
234 Status::Failed => counts.failed += 1,
235 Status::Unsupported => counts.unsupported += 1,
236 Status::SkippedUnchanged => counts.skipped_unchanged += 1,
237 Status::Dedup => counts.dedup += 1,
238 }
239 counts.total += 1;
240 }
241 counts
242}
243
244fn tally_coverage(effective: &BTreeMap<String, &Record>) -> DivisionCoverage {
246 let mut coverage = DivisionCoverage::default();
247 for record in effective.values() {
248 coverage.total += 1;
249 if covered_status(record.status) {
250 coverage.covered += 1;
251 }
252 }
253 coverage
254}
255
256fn enumerate_files(root: &Path) -> Result<Vec<String>> {
260 fn visit(dir: &Path, base: &Path, out: &mut Vec<String>) -> Result<()> {
261 let entries = fs::read_dir(dir).map_err(|e| Error::io("read_dir", dir, e))?;
262 for entry in entries {
263 let entry = entry.map_err(|e| Error::io("read_dir", dir, e))?;
264 let path = entry.path();
265 let file_type = entry.file_type().map_err(|e| Error::io("stat", &path, e))?;
266 if file_type.is_dir() {
267 visit(&path, base, out)?;
268 } else if file_type.is_file() {
269 let relative = path
270 .strip_prefix(base)
271 .expect("entries live under the base")
272 .components()
273 .map(|c| c.as_os_str().to_string_lossy())
274 .collect::<Vec<_>>()
275 .join("/");
276 out.push(relative);
277 } else {
278 return Err(Error::io(
279 "enumerate",
280 &path,
281 std::io::Error::new(
282 std::io::ErrorKind::InvalidInput,
283 "bundle trees hold regular files only",
284 ),
285 ));
286 }
287 }
288 Ok(())
289 }
290 let mut files = Vec::new();
291 visit(root, root, &mut files)?;
292 files.sort();
293 Ok(files)
294}
295
296fn checksum_lines(root: &Path, files: &[String]) -> Result<String> {
300 let mut out = String::new();
301 for relative in files {
302 let digest = hash::hash_file(&root.join(relative))?;
303 out.push_str(&digest);
304 out.push_str(" ");
305 out.push_str(relative);
306 out.push('\n');
307 }
308 Ok(out)
309}
310
311fn write_file(root: &Path, relative: &str, bytes: &[u8]) -> Result<()> {
312 let dest = root.join(relative);
313 if let Some(dir) = dest.parent() {
314 fs::create_dir_all(dir).map_err(|e| Error::io("create_dir", dir, e))?;
315 }
316 fs::write(&dest, bytes).map_err(|e| Error::io("write", &dest, e))
317}
318
319fn copy_tree(from: &Path, to: &Path) -> Result<u64> {
322 let files = enumerate_files(from)?;
323 for relative in &files {
324 let source = from.join(relative);
325 let dest = to.join(relative);
326 if let Some(dir) = dest.parent() {
327 fs::create_dir_all(dir).map_err(|e| Error::io("create_dir", dir, e))?;
328 }
329 fs::copy(&source, &dest).map_err(|e| Error::io("copy", &source, e))?;
330 }
331 Ok(files.len() as u64)
332}
333
334fn descriptor_bytes(descriptor: &BundleDescriptor) -> Result<Vec<u8>> {
335 let mut json = serde_json::to_string_pretty(descriptor).map_err(|e| Error::Encode {
336 path: PathBuf::from(DESCRIPTOR_NAME),
337 message: e.to_string(),
338 })?;
339 json.push('\n');
340 Ok(json.into_bytes())
341}
342
343fn refuse_dirty_output(output: &Path) -> Result<()> {
344 if output.exists() {
345 let mut entries = fs::read_dir(output).map_err(|e| Error::io("read_dir", output, e))?;
346 if entries.next().is_some() {
347 return Err(Error::Layout {
348 message: format!("output {} exists and is not empty", output.display()),
349 });
350 }
351 }
352 Ok(())
353}
354
355pub struct BundleOptions<'a> {
357 pub mirror_root: &'a Path,
359 pub manifest_dir: &'a Path,
361 pub division: &'a str,
363 pub output: &'a Path,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct BundleReport {
370 pub division: String,
372 pub output: String,
374 pub files: u64,
376 pub counts: DivisionCounts,
378 pub coverage: DivisionCoverage,
380 pub checksums_hash: String,
382}
383
384pub fn bundle(options: &BundleOptions) -> Result<BundleReport> {
386 crate::pipeline::validate_division(options.division)?;
387 let shard_path = options
388 .manifest_dir
389 .join(format!("{}.jsonl", options.division));
390 let shard_bytes = fs::read(&shard_path).map_err(|e| Error::io("read", &shard_path, e))?;
391 if !shard_bytes.ends_with(b"\n") {
396 return Err(refused(
397 "bundle",
398 vec![format!(
399 "shard {} does not end with a newline, resume the run to complete the division",
400 shard_path.display()
401 )],
402 ));
403 }
404 let shard = manifest::read_shard(&shard_path)?;
405 if !shard.warnings.is_empty() {
406 return Err(refused(
407 "bundle",
408 vec![format!(
409 "shard {} ends in a torn line, resume the run to complete the division",
410 shard_path.display()
411 )],
412 ));
413 }
414 let effective = effective_records(&shard.records);
415 let counts = tally_counts(&shard.records);
416 let coverage = tally_coverage(&effective);
417
418 let mut problems = Vec::new();
421 for record in effective.values() {
422 let Some(text_path) = &record.text_path else {
423 continue;
424 };
425 let present = crate::mirror::resolve_recorded_path(options.mirror_root, text_path)
426 .is_ok_and(|artifact| artifact.is_file());
427 if !present {
428 problems.push(format!(
429 "{}: artifact {text_path} is missing",
430 record.source_path
431 ));
432 }
433 }
434 if !problems.is_empty() {
435 return Err(refused("bundle", problems));
436 }
437
438 let snapshot_version =
444 rules_file_version(include_str!("../rules/converters.toml"), "converters.toml").map_err(
445 |message| Error::Rules {
446 name: "converters.toml".to_string(),
447 message,
448 },
449 )?;
450 let mut problems = Vec::new();
451 for record in effective.values() {
452 if record.rules_version != snapshot_version {
453 problems.push(format!(
454 "{}: record rules_version {:?} does not match the snapshot rules version {:?}, re-run the division under the current rules",
455 record.source_path, record.rules_version, snapshot_version
456 ));
457 }
458 }
459 if !problems.is_empty() {
460 return Err(refused("bundle", problems));
461 }
462
463 refuse_dirty_output(options.output)?;
464 fs::create_dir_all(options.output).map_err(|e| Error::io("create_dir", options.output, e))?;
465
466 let bundle_mirror = options.output.join("mirror").join(options.division);
470 fs::create_dir_all(&bundle_mirror).map_err(|e| Error::io("create_dir", &bundle_mirror, e))?;
471 let division_mirror = options.mirror_root.join(options.division);
472 if division_mirror.is_dir() {
473 copy_tree(&division_mirror, &bundle_mirror)?;
474 }
475 write_file(
476 options.output,
477 &format!("manifest/{}.jsonl", options.division),
478 &shard_bytes,
479 )?;
480 write_file(
481 options.output,
482 "rules/formats.toml",
483 include_str!("../rules/formats.toml").as_bytes(),
484 )?;
485 write_file(
486 options.output,
487 "rules/converters.toml",
488 include_str!("../rules/converters.toml").as_bytes(),
489 )?;
490
491 let files = enumerate_files(options.output)?;
492 let checksums = checksum_lines(options.output, &files)?;
493 let checksums_hash = hash::hash_bytes(checksums.as_bytes());
494 write_file(options.output, CHECKSUMS_NAME, checksums.as_bytes())?;
495
496 let descriptor = BundleDescriptor {
497 schema: BundleSchema,
498 manifest_schema: ManifestSchema,
499 run_ids: {
500 let run_id = hash::hash_bytes(&shard_bytes);
501 if !valid_run_id(&run_id) {
502 return Err(refused(
503 "bundle",
504 vec![format!("run id {run_id:?} is outside the run id grammar")],
505 ));
506 }
507 vec![run_id]
508 },
509 divisions: vec![options.division.to_string()],
510 counts: BTreeMap::from([(options.division.to_string(), counts.clone())]),
511 coverage: BTreeMap::from([(options.division.to_string(), coverage.clone())]),
512 checksums_hash: checksums_hash.clone(),
513 };
514 write_file(
515 options.output,
516 DESCRIPTOR_NAME,
517 &descriptor_bytes(&descriptor)?,
518 )?;
519
520 verify(options.output).map_err(|error| match error {
523 Error::Refused { problems, .. } => refused("bundle", problems),
524 other => other,
525 })?;
526
527 Ok(BundleReport {
528 division: options.division.to_string(),
529 output: options.output.display().to_string(),
530 files: files.len() as u64,
531 counts,
532 coverage,
533 checksums_hash,
534 })
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct VerifyReport {
540 pub bundle: String,
542 pub divisions: Vec<String>,
544 pub files_verified: u64,
546 pub records: u64,
548 pub counts: BTreeMap<String, DivisionCounts>,
550 pub coverage: BTreeMap<String, DivisionCoverage>,
552}
553
554fn parse_checksum_lines(content: &str) -> std::result::Result<Vec<(String, String)>, Vec<String>> {
555 let mut problems = Vec::new();
556 let mut entries: Vec<(String, String)> = Vec::new();
557 let mut seen = BTreeSet::new();
558 for (index, line) in content.lines().enumerate() {
559 let number = index + 1;
560 let Some((digest, path)) = line.split_once(" ") else {
561 problems.push(format!("checksums.b3 line {number}: not a checksum line"));
562 continue;
563 };
564 let digest_ok = digest.len() == 64 && digest.chars().all(|c| c.is_ascii_hexdigit());
565 let path_ok = !path.is_empty()
568 && !path.contains(['\\', ':'])
569 && path
570 .split('/')
571 .all(|segment| !segment.is_empty() && segment != "." && segment != "..");
572 if !digest_ok || !path_ok {
573 problems.push(format!("checksums.b3 line {number}: malformed entry"));
574 continue;
575 }
576 if !seen.insert(path.to_string()) {
577 problems.push(format!("checksums.b3 line {number}: duplicate path {path}"));
578 continue;
579 }
580 if let Some((_, previous)) = entries.last()
581 && path <= previous.as_str()
582 {
583 problems.push(format!(
584 "checksums.b3 line {number}: paths are not in bytewise order"
585 ));
586 continue;
587 }
588 entries.push((digest.to_ascii_lowercase(), path.to_string()));
589 }
590 if problems.is_empty() {
591 Ok(entries)
592 } else {
593 Err(problems)
594 }
595}
596
597fn read_descriptor(root: &Path) -> Result<BundleDescriptor> {
598 let path = root.join(DESCRIPTOR_NAME);
599 let bytes = fs::read(&path)
600 .map_err(|e| refused("verify", vec![format!("{DESCRIPTOR_NAME} unreadable: {e}")]))?;
601 serde_json::from_slice(&bytes)
602 .map_err(|e| refused("verify", vec![format!("{DESCRIPTOR_NAME}: {e}")]))
603}
604
605pub fn verify(root: &Path) -> Result<VerifyReport> {
619 let verb = "verify";
620
621 let descriptor = read_descriptor(root)?;
627 let mut problems = Vec::new();
628 for (index, run_id) in descriptor.run_ids.iter().enumerate() {
629 if !valid_run_id(run_id) {
630 problems.push(format!("run id {run_id:?} is outside the run id grammar"));
631 }
632 if index > 0 {
633 match run_id.cmp(&descriptor.run_ids[index - 1]) {
634 std::cmp::Ordering::Greater => {}
635 std::cmp::Ordering::Equal => {
636 problems.push(format!("duplicate run id {run_id:?}"));
637 }
638 std::cmp::Ordering::Less => {
639 problems.push(format!("run id {run_id:?} is not in bytewise order"));
640 }
641 }
642 }
643 }
644 let mut folded = BTreeSet::new();
645 for (index, division) in descriptor.divisions.iter().enumerate() {
646 if crate::pipeline::validate_division(division).is_err() {
647 problems.push(format!("division name {division:?} is outside the grammar"));
648 continue;
649 }
650 if index > 0 && division <= &descriptor.divisions[index - 1] {
651 problems.push(format!(
652 "division {division:?} is duplicated or not in bytewise order"
653 ));
654 }
655 if !folded.insert(division.to_ascii_lowercase()) {
656 problems.push(format!(
657 "division {division:?} collides with another under ASCII case folding"
658 ));
659 }
660 }
661 if !problems.is_empty() {
662 return Err(refused(verb, problems));
663 }
664
665 let entries = enumerate_entries(root)?;
671 let mut problems = Vec::new();
672 let mut expected_shards: BTreeSet<String> = descriptor
673 .divisions
674 .iter()
675 .map(|d| format!("{d}.jsonl"))
676 .collect();
677 let division_set: BTreeSet<&str> = descriptor.divisions.iter().map(String::as_str).collect();
678 let mut dirs_with_files: BTreeSet<String> = BTreeSet::new();
679 for (path, is_dir) in &entries {
680 if !is_dir {
681 let mut ancestor = path.as_str();
682 while let Some(slash) = ancestor.rfind('/') {
683 ancestor = &ancestor[..slash];
684 dirs_with_files.insert(ancestor.to_string());
685 }
686 }
687 }
688 for (path, is_dir) in &entries {
689 let segments: Vec<&str> = path.split('/').collect();
690 let expected = match (segments.as_slice(), *is_dir) {
691 ([name], false) if *name == DESCRIPTOR_NAME || *name == CHECKSUMS_NAME => true,
692 (["manifest"] | ["mirror"] | ["rules"], true) => true,
693 (["manifest", shard], false) => expected_shards.remove(*shard),
694 (["rules", file], false) => *file == "formats.toml" || *file == "converters.toml",
695 (["mirror", division], true) => division_set.contains(division),
696 (["mirror", division, ..], is_dir) => {
697 division_set.contains(division) && (!is_dir || dirs_with_files.contains(path))
698 }
699 _ => false,
700 };
701 if !expected {
702 let what = if *is_dir && dirs_with_files.contains(path) {
703 "unexpected entry"
704 } else if *is_dir {
705 "empty directory"
706 } else {
707 "unexpected entry"
708 };
709 problems.push(format!("{what}: {path}"));
710 }
711 }
712 for missing in &expected_shards {
713 problems.push(format!("missing shard: manifest/{missing}"));
714 }
715 for required in [
716 DESCRIPTOR_NAME,
717 CHECKSUMS_NAME,
718 "manifest",
719 "mirror",
720 "rules",
721 ] {
722 if !entries.iter().any(|(path, _)| path == required) {
723 problems.push(format!("missing bundle entry: {required}"));
724 }
725 }
726 for name in ["rules/formats.toml", "rules/converters.toml"] {
727 if !entries.iter().any(|(path, is_dir)| path == name && !is_dir) {
728 problems.push(format!("missing bundle entry: {name}"));
729 }
730 }
731 for division in &descriptor.divisions {
732 let dir = format!("mirror/{division}");
733 if !entries.iter().any(|(path, is_dir)| *path == dir && *is_dir) {
734 problems.push(format!("missing mirror subtree: {dir}"));
735 }
736 }
737 if !problems.is_empty() {
738 return Err(refused(verb, problems));
739 }
740
741 let checksums_path = root.join(CHECKSUMS_NAME);
747 let checksums_bytes = fs::read(&checksums_path)
748 .map_err(|e| refused(verb, vec![format!("{CHECKSUMS_NAME} unreadable: {e}")]))?;
749 if checksums_bytes.contains(&b'\r') {
750 return Err(refused(
751 verb,
752 vec![format!(
753 "{CHECKSUMS_NAME} contains a carriage return, the file is LF only"
754 )],
755 ));
756 }
757 if !checksums_bytes.ends_with(b"\n") {
758 return Err(refused(
759 verb,
760 vec![format!("{CHECKSUMS_NAME} does not end with a newline")],
761 ));
762 }
763 let actual = hash::hash_bytes(&checksums_bytes);
764 if actual != descriptor.checksums_hash {
765 return Err(refused(
766 verb,
767 vec![format!(
768 "{CHECKSUMS_NAME} hashes to {actual} but the descriptor records {}",
769 descriptor.checksums_hash
770 )],
771 ));
772 }
773 let checksums_text = String::from_utf8(checksums_bytes)
774 .map_err(|_| refused(verb, vec![format!("{CHECKSUMS_NAME} is not UTF-8")]))?;
775 let listed =
776 parse_checksum_lines(&checksums_text).map_err(|problems| refused(verb, problems))?;
777 let tree_files: BTreeSet<&str> = entries
778 .iter()
779 .filter(|(path, is_dir)| !is_dir && path != DESCRIPTOR_NAME && path != CHECKSUMS_NAME)
780 .map(|(path, _)| path.as_str())
781 .collect();
782 let listed_map: BTreeMap<&str, &str> = listed
783 .iter()
784 .map(|(digest, path)| (path.as_str(), digest.as_str()))
785 .collect();
786 let mut problems = Vec::new();
787 for path in listed_map.keys() {
788 if !tree_files.contains(path) {
789 problems.push(format!("listed file missing: {path}"));
790 }
791 }
792 for path in &tree_files {
793 if !listed_map.contains_key(path) {
794 problems.push(format!("unlisted file: {path}"));
795 }
796 }
797 if !problems.is_empty() {
798 return Err(refused(verb, problems));
799 }
800 let mut computed: HashMap<String, String> = HashMap::new();
801 for path in &tree_files {
802 let digest = hash::hash_file(&root.join(path))?;
803 if digest != listed_map[path] {
804 problems.push(format!("checksum mismatch: {path}"));
805 }
806 computed.insert((*path).to_string(), digest);
807 }
808 if !problems.is_empty() {
809 return Err(refused(verb, problems));
810 }
811
812 let mut shards: Vec<(String, Vec<Record>)> = Vec::new();
816 let mut record_count = 0u64;
817 let mut problems = Vec::new();
818 for division in &descriptor.divisions {
819 let relative = format!("manifest/{division}.jsonl");
820 match manifest::read_shard_strict(&root.join(&relative)) {
821 Ok(records) => {
822 record_count += records.len() as u64;
823 shards.push((division.clone(), records));
824 }
825 Err(error) => problems.push(format!("{relative}: {error}")),
826 }
827 }
828 if !problems.is_empty() {
829 return Err(refused(verb, problems));
830 }
831
832 let mut problems = Vec::new();
839 let formats_text = fs::read_to_string(root.join("rules/formats.toml"))
840 .map_err(|e| Error::io("read", &root.join("rules/formats.toml"), e))?;
841 let converters_text = fs::read_to_string(root.join("rules/converters.toml"))
842 .map_err(|e| Error::io("read", &root.join("rules/converters.toml"), e))?;
843 let snapshot_version = match (
844 rules_file_version(&formats_text, "rules/formats.toml"),
845 rules_file_version(&converters_text, "rules/converters.toml"),
846 ) {
847 (Ok(formats_version), Ok(converters_version)) => {
848 if formats_version != converters_version {
849 return Err(refused(
850 verb,
851 vec![format!(
852 "rules snapshot files disagree: formats.toml declares {formats_version:?}, converters.toml declares {converters_version:?}"
853 )],
854 ));
855 }
856 formats_version
857 }
858 (formats, converters) => {
859 let mut problems = Vec::new();
860 if let Err(message) = formats {
861 problems.push(message);
862 }
863 if let Err(message) = converters {
864 problems.push(message);
865 }
866 return Err(refused(verb, problems));
867 }
868 };
869 let mut referenced: BTreeSet<String> = BTreeSet::new();
870 let mut owners: BTreeMap<String, Vec<String>> = BTreeMap::new();
871 let mut counts = BTreeMap::new();
872 let mut coverage = BTreeMap::new();
873 let mut effective_by_division = Vec::new();
874 for (division, records) in &shards {
875 let effective = effective_records(records);
876 counts.insert(division.clone(), tally_counts(records));
877 coverage.insert(division.clone(), tally_coverage(&effective));
878 effective_by_division.push((division.clone(), effective));
879 }
880 for (division, effective) in &effective_by_division {
881 for record in effective.values() {
882 if record.rules_version != snapshot_version {
883 problems.push(format!(
884 "{division}:{}: record rules_version {:?} does not match the snapshot rules version {snapshot_version:?}",
885 record.source_path, record.rules_version
886 ));
887 }
888 let (Some(text_path), Some(text_hash)) = (&record.text_path, &record.text_hash) else {
889 continue;
890 };
891 let expected = crate::mirror::recorded_text_path(division, &record.source_path);
892 if *text_path != expected {
893 problems.push(format!(
894 "{division}:{}: text_path {text_path} is not the pure mapping {expected}",
895 record.source_path
896 ));
897 continue;
898 }
899 owners
900 .entry(collision_key(text_path))
901 .or_default()
902 .push(format!("{division}:{}", record.source_path));
903 let bundle_path = format!("mirror/{text_path}");
904 match computed.get(bundle_path.as_str()) {
905 Some(actual) if actual == text_hash => {}
906 Some(_) => problems.push(format!(
907 "{division}:{}: artifact {text_path} does not hash to the recorded text_hash",
908 record.source_path
909 )),
910 None => problems.push(format!(
911 "{division}:{}: artifact {text_path} is missing",
912 record.source_path
913 )),
914 }
915 referenced.insert(bundle_path.clone());
916 if let Some(base) = bundle_path.strip_suffix(".txt") {
917 referenced.insert(format!("{base}.segments.jsonl"));
918 referenced.insert(format!("{base}.review.md"));
919 }
920 }
921 }
922 for path in &tree_files {
923 if path.starts_with("mirror/") && !referenced.contains(*path) {
924 problems.push(format!("unreferenced mirror file: {path}"));
925 }
926 }
927 if !problems.is_empty() {
928 return Err(refused(verb, problems));
929 }
930
931 let mut problems = Vec::new();
936 for (key, owner) in &owners {
937 if owner.len() > 1 {
938 problems.push(format!(
939 "text_path collision on {key}: {}",
940 owner.join(", ")
941 ));
942 }
943 }
944 if !problems.is_empty() {
945 return Err(refused(verb, problems));
946 }
947
948 let mut problems = Vec::new();
951 let count_fields = |c: &DivisionCounts| {
952 [
953 ("converted", c.converted),
954 ("failed", c.failed),
955 ("unsupported", c.unsupported),
956 ("skipped_unchanged", c.skipped_unchanged),
957 ("dedup", c.dedup),
958 ("total", c.total),
959 ]
960 };
961 let divisions_union: BTreeSet<&String> =
962 descriptor.counts.keys().chain(counts.keys()).collect();
963 for division in divisions_union {
964 match (descriptor.counts.get(division), counts.get(division)) {
965 (Some(recorded), Some(recomputed)) => {
966 for ((field, recorded), (_, recomputed)) in count_fields(recorded)
967 .into_iter()
968 .zip(count_fields(recomputed))
969 {
970 if recorded != recomputed {
971 problems.push(format!(
972 "counts.{field} for {division}: descriptor {recorded}, shards {recomputed}"
973 ));
974 }
975 }
976 }
977 (Some(_), None) => problems.push(format!("counts for {division}: no such shard")),
978 (None, Some(_)) => {
979 problems.push(format!("counts for {division}: absent from the descriptor"));
980 }
981 (None, None) => {}
982 }
983 }
984 let coverage_fields = |c: &DivisionCoverage| [("covered", c.covered), ("total", c.total)];
985 let divisions_union: BTreeSet<&String> =
986 descriptor.coverage.keys().chain(coverage.keys()).collect();
987 for division in divisions_union {
988 match (descriptor.coverage.get(division), coverage.get(division)) {
989 (Some(recorded), Some(recomputed)) => {
990 for ((field, recorded), (_, recomputed)) in coverage_fields(recorded)
991 .into_iter()
992 .zip(coverage_fields(recomputed))
993 {
994 if recorded != recomputed {
995 problems.push(format!(
996 "coverage.{field} for {division}: descriptor {recorded}, shards {recomputed}"
997 ));
998 }
999 }
1000 }
1001 (Some(_), None) => problems.push(format!("coverage for {division}: no such shard")),
1002 (None, Some(_)) => {
1003 problems.push(format!(
1004 "coverage for {division}: absent from the descriptor"
1005 ));
1006 }
1007 (None, None) => {}
1008 }
1009 }
1010 if !problems.is_empty() {
1011 return Err(refused(verb, problems));
1012 }
1013
1014 Ok(VerifyReport {
1015 bundle: root.display().to_string(),
1016 divisions: descriptor.divisions.clone(),
1017 files_verified: listed.len() as u64,
1018 records: record_count,
1019 counts,
1020 coverage,
1021 })
1022}
1023
1024#[derive(Debug, Clone, Serialize, Deserialize)]
1026pub struct MergeReport {
1027 pub output: String,
1029 pub inputs: u64,
1031 pub divisions: Vec<String>,
1033 pub checksums_hash: String,
1035}
1036
1037pub fn merge(inputs: &[PathBuf], output: &Path) -> Result<MergeReport> {
1044 let verb = "merge";
1045 if inputs.len() < 2 {
1046 return Err(Error::Layout {
1047 message: "merge needs at least two input bundles".to_string(),
1048 });
1049 }
1050
1051 let mut descriptors = Vec::new();
1055 for input in inputs {
1056 verify(input).map_err(|error| match error {
1057 Error::Refused { problems, .. } => refused(
1058 verb,
1059 problems
1060 .into_iter()
1061 .map(|p| format!("input {}: {p}", input.display()))
1062 .collect(),
1063 ),
1064 other => other,
1065 })?;
1066 descriptors.push((input.clone(), read_descriptor(input)?));
1067 }
1068
1069 let mut problems = Vec::new();
1073 let mut owners: BTreeMap<String, Vec<String>> = BTreeMap::new();
1074 for (input, descriptor) in &descriptors {
1075 for division in &descriptor.divisions {
1076 owners
1077 .entry(division.to_ascii_lowercase())
1078 .or_default()
1079 .push(format!("{division} in {}", input.display()));
1080 }
1081 }
1082 for owner in owners.values() {
1083 if owner.len() > 1 {
1084 problems.push(format!(
1085 "division appears in more than one input: {}",
1086 owner.join(", ")
1087 ));
1088 }
1089 }
1090
1091 let rules_snapshot = |root: &Path| -> Result<BTreeMap<String, Vec<u8>>> {
1093 let rules_root = root.join("rules");
1094 let mut snapshot = BTreeMap::new();
1095 for relative in enumerate_files(&rules_root)? {
1096 let bytes = fs::read(rules_root.join(&relative))
1097 .map_err(|e| Error::io("read", &rules_root.join(&relative), e))?;
1098 snapshot.insert(relative, bytes);
1099 }
1100 Ok(snapshot)
1101 };
1102 let first_rules = rules_snapshot(&descriptors[0].0)?;
1103 for (input, _) in &descriptors[1..] {
1104 if rules_snapshot(input)? != first_rules {
1105 problems.push(format!(
1106 "rules snapshot in {} differs from {}",
1107 input.display(),
1108 descriptors[0].0.display()
1109 ));
1110 }
1111 }
1112 if !problems.is_empty() {
1113 return Err(refused(verb, problems));
1114 }
1115
1116 refuse_dirty_output(output)?;
1117 fs::create_dir_all(output).map_err(|e| Error::io("create_dir", output, e))?;
1118
1119 let mut checksum_entries: Vec<String> = Vec::new();
1122 for (input, _descriptor) in &descriptors {
1123 for relative in enumerate_files(input)? {
1124 if relative == DESCRIPTOR_NAME || relative == CHECKSUMS_NAME {
1125 continue;
1126 }
1127 if relative.starts_with("rules/") && *input != descriptors[0].0 {
1128 continue;
1129 }
1130 let source = input.join(&relative);
1131 let dest = output.join(&relative);
1132 if let Some(dir) = dest.parent() {
1133 fs::create_dir_all(dir).map_err(|e| Error::io("create_dir", dir, e))?;
1134 }
1135 fs::copy(&source, &dest).map_err(|e| Error::io("copy", &source, e))?;
1136 }
1137 let checksums = fs::read_to_string(input.join(CHECKSUMS_NAME))
1138 .map_err(|e| Error::io("read", &input.join(CHECKSUMS_NAME), e))?;
1139 checksum_entries.extend(checksums.lines().map(str::to_string));
1140 }
1141 for (_, descriptor) in &descriptors {
1144 for division in &descriptor.divisions {
1145 let dir = output.join("mirror").join(division);
1146 fs::create_dir_all(&dir).map_err(|e| Error::io("create_dir", &dir, e))?;
1147 }
1148 }
1149 checksum_entries.sort_by(|a, b| {
1152 let path_of = |line: &str| line.split_once(" ").map(|(_, p)| p.to_string());
1153 path_of(a).cmp(&path_of(b)).then_with(|| a.cmp(b))
1154 });
1155 checksum_entries.dedup();
1156 let mut checksums = checksum_entries.join("\n");
1157 checksums.push('\n');
1158 let checksums_hash = hash::hash_bytes(checksums.as_bytes());
1159 write_file(output, CHECKSUMS_NAME, checksums.as_bytes())?;
1160
1161 let mut run_ids: Vec<String> = descriptors
1164 .iter()
1165 .flat_map(|(_, d)| d.run_ids.iter().cloned())
1166 .collect();
1167 run_ids.sort();
1168 run_ids.dedup();
1169 let mut divisions: Vec<String> = descriptors
1170 .iter()
1171 .flat_map(|(_, d)| d.divisions.iter().cloned())
1172 .collect();
1173 divisions.sort();
1174 let mut counts = BTreeMap::new();
1175 let mut coverage = BTreeMap::new();
1176 for (_, descriptor) in &descriptors {
1177 counts.extend(descriptor.counts.clone());
1178 coverage.extend(descriptor.coverage.clone());
1179 }
1180 let descriptor = BundleDescriptor {
1181 schema: BundleSchema,
1182 manifest_schema: ManifestSchema,
1183 run_ids,
1184 divisions: divisions.clone(),
1185 counts,
1186 coverage,
1187 checksums_hash: checksums_hash.clone(),
1188 };
1189 write_file(output, DESCRIPTOR_NAME, &descriptor_bytes(&descriptor)?)?;
1190
1191 verify(output).map_err(|error| match error {
1193 Error::Refused { problems, .. } => refused(
1194 verb,
1195 problems
1196 .into_iter()
1197 .map(|p| format!("merged output: {p}"))
1198 .collect(),
1199 ),
1200 other => other,
1201 })?;
1202
1203 Ok(MergeReport {
1204 output: output.display().to_string(),
1205 inputs: inputs.len() as u64,
1206 divisions,
1207 checksums_hash,
1208 })
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213 use super::*;
1214
1215 #[test]
1216 fn run_id_grammar_accepts_and_rejects() {
1217 assert!(valid_run_id("a"));
1218 assert!(valid_run_id(&"f".repeat(64)));
1219 assert!(valid_run_id("run-1.2_3"));
1220 assert!(!valid_run_id(""));
1221 assert!(!valid_run_id(&"f".repeat(65)));
1222 assert!(!valid_run_id("-leading-dash"));
1223 assert!(!valid_run_id(".leading-dot"));
1224 assert!(!valid_run_id("has space"));
1225 assert!(!valid_run_id("ctrl\u{7}char"));
1226 assert!(!valid_run_id("caf\u{e9}"));
1227 }
1228
1229 #[test]
1230 fn collision_keys_fold_ascii_case_and_nfc() {
1231 assert_eq!(
1232 collision_key("emea/A.TXT.txt"),
1233 collision_key("emea/a.txt.txt")
1234 );
1235 assert_eq!(
1237 collision_key("emea/r\u{e9}sum\u{e9}.txt"),
1238 collision_key("emea/re\u{301}sume\u{301}.txt")
1239 );
1240 assert_ne!(
1242 collision_key("emea/\u{c9}.txt"),
1243 collision_key("emea/\u{e9}.txt")
1244 );
1245 assert_ne!(collision_key("emea/a.txt"), collision_key("apac/a.txt"));
1246 }
1247}