1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use clayers_xml::c14n;
5
6use crate::artifact;
7
8#[derive(Debug)]
10pub struct FixResult {
11 pub mapping_id: String,
12 pub old_hash: String,
13 pub new_hash: String,
14}
15
16#[derive(Debug)]
18pub struct FixReport {
19 pub spec_name: String,
20 pub total_mappings: usize,
21 pub fixed_count: usize,
22 pub results: Vec<FixResult>,
23}
24
25pub fn fix_artifact_hashes(spec_dir: &Path) -> Result<FixReport, crate::Error> {
35 let spec_name = spec_dir
36 .file_name()
37 .map_or_else(|| "unknown".into(), |n| n.to_string_lossy().into_owned());
38
39 let index_files = crate::discovery::find_index_files(spec_dir)?;
40 let mut all_file_paths = Vec::new();
41 for index_path in &index_files {
42 let file_paths = crate::discovery::discover_spec_files(index_path)?;
43 all_file_paths.extend(file_paths);
44 }
45
46 let repo_root = artifact::find_repo_root(spec_dir);
47 let mappings = artifact::collect_artifact_mappings(&all_file_paths)?;
48 let total_mappings = mappings.len();
49
50 let mut results = Vec::new();
51 let mut file_changes: HashMap<PathBuf, Vec<RangeChange>> = HashMap::new();
52
53 for mapping in &mappings {
54 for range in &mapping.ranges {
55 let artifact_path = artifact::resolve_artifact_path(
56 &mapping.artifact_path,
57 spec_dir,
58 repo_root.as_deref(),
59 );
60
61 if !artifact_path.exists() {
62 continue;
63 }
64
65 let new_hash =
66 if let (Some(start), Some(end)) = (range.start_line, range.end_line) {
67 artifact::hash_line_range(&artifact_path, start, end)?
68 } else {
69 artifact::hash_file(&artifact_path)?
70 };
71
72 let new_hash_str = new_hash.to_prefixed();
73 let old_hash_str = range.hash.clone().unwrap_or_default();
74
75 if old_hash_str != new_hash_str {
76 results.push(FixResult {
77 mapping_id: mapping.id.clone(),
78 old_hash: old_hash_str.clone(),
79 new_hash: new_hash_str.clone(),
80 });
81
82 file_changes
83 .entry(mapping.source_file.clone())
84 .or_default()
85 .push(RangeChange {
86 mapping_id: mapping.id.clone(),
87 old_hash: old_hash_str,
88 new_hash: new_hash_str,
89 start_line: range.start_line,
90 end_line: range.end_line,
91 });
92 }
93 }
94 }
95
96 for (file_path, changes) in &file_changes {
97 apply_range_changes(file_path, changes)?;
98 }
99
100 let fixed_count = results.len();
101 Ok(FixReport {
102 spec_name,
103 total_mappings,
104 fixed_count,
105 results,
106 })
107}
108
109pub fn fix_node_hashes(spec_dir: &Path) -> Result<FixReport, crate::Error> {
119 let spec_name = spec_dir
120 .file_name()
121 .map_or_else(|| "unknown".into(), |n| n.to_string_lossy().into_owned());
122
123 let index_files = crate::discovery::find_index_files(spec_dir)?;
124 let mut all_file_paths = Vec::new();
125 for index_path in &index_files {
126 let file_paths = crate::discovery::discover_spec_files(index_path)?;
127 all_file_paths.extend(file_paths);
128 }
129
130 let (mut xot, root) = crate::assembly::assemble_combined(&all_file_paths)?;
131 let mappings = artifact::collect_artifact_mappings(&all_file_paths)?;
132 let total_mappings = mappings.len();
133
134 let id_attr = xot.add_name("id");
135 let xml_ns = xot.add_namespace(crate::namespace::XML);
136 let xml_id_attr = xot.add_name_ns("id", xml_ns);
137
138 let mut results = Vec::new();
139 let mut file_changes: HashMap<PathBuf, Vec<NodeHashChange>> = HashMap::new();
140
141 for mapping in &mappings {
142 let old_hash = match &mapping.node_hash {
143 Some(h) => h.clone(),
144 None => continue,
145 };
146
147 if mapping.spec_ref_node.is_empty() {
148 continue;
149 }
150
151 let Some(node) =
152 find_node_by_id(&xot, root, id_attr, xml_id_attr, &mapping.spec_ref_node)
153 else {
154 continue;
155 };
156
157 let xml_str = xot.to_string(node).unwrap_or_default();
158 let Ok(new_hash) =
159 c14n::canonicalize_and_hash(&xml_str, c14n::CanonicalizationMode::Inclusive)
160 else {
161 continue;
162 };
163
164 let new_hash_str = new_hash.to_prefixed();
165 if old_hash != new_hash_str {
166 results.push(FixResult {
167 mapping_id: mapping.id.clone(),
168 old_hash: old_hash.clone(),
169 new_hash: new_hash_str.clone(),
170 });
171
172 file_changes
173 .entry(mapping.source_file.clone())
174 .or_default()
175 .push(NodeHashChange {
176 mapping_id: mapping.id.clone(),
177 old_hash,
178 new_hash: new_hash_str,
179 });
180 }
181 }
182
183 for (file_path, changes) in &file_changes {
184 apply_node_hash_changes(file_path, changes)?;
185 }
186
187 let fixed_count = results.len();
188 Ok(FixReport {
189 spec_name,
190 total_mappings,
191 fixed_count,
192 results,
193 })
194}
195
196struct RangeChange {
199 mapping_id: String,
200 old_hash: String,
201 new_hash: String,
202 start_line: Option<u64>,
203 end_line: Option<u64>,
204}
205
206struct NodeHashChange {
207 mapping_id: String,
208 old_hash: String,
209 new_hash: String,
210}
211
212fn find_node_by_id(
215 xot: &xot::Xot,
216 node: xot::Node,
217 id_attr: xot::NameId,
218 xml_id_attr: xot::NameId,
219 target_id: &str,
220) -> Option<xot::Node> {
221 if xot.is_element(node) {
222 if xot.get_attribute(node, id_attr)
224 .is_some_and(|id| id == target_id)
225 {
226 return Some(node);
227 }
228 if xot.get_attribute(node, xml_id_attr)
230 .is_some_and(|id| id == target_id)
231 {
232 return Some(node);
233 }
234 }
235 for child in xot.children(node) {
236 if let Some(found) = find_node_by_id(xot, child, id_attr, xml_id_attr, target_id) {
237 return Some(found);
238 }
239 }
240 None
241}
242
243fn apply_range_changes(file_path: &Path, changes: &[RangeChange]) -> Result<(), crate::Error> {
244 let mut content = std::fs::read_to_string(file_path)?;
245
246 for change in changes {
247 content = replace_hash_in_mapping(
248 &content,
249 &change.mapping_id,
250 "hash",
251 &change.old_hash,
252 &change.new_hash,
253 change.start_line,
254 change.end_line,
255 );
256 }
257
258 std::fs::write(file_path, content)?;
259 Ok(())
260}
261
262fn apply_node_hash_changes(
263 file_path: &Path,
264 changes: &[NodeHashChange],
265) -> Result<(), crate::Error> {
266 let mut content = std::fs::read_to_string(file_path)?;
267
268 for change in changes {
269 content = replace_hash_in_mapping(
270 &content,
271 &change.mapping_id,
272 "node-hash",
273 &change.old_hash,
274 &change.new_hash,
275 None,
276 None,
277 );
278 }
279
280 std::fs::write(file_path, content)?;
281 Ok(())
282}
283
284fn replace_hash_in_mapping(
290 content: &str,
291 mapping_id: &str,
292 attr_name: &str,
293 old_hash: &str,
294 new_hash: &str,
295 start_line: Option<u64>,
296 end_line: Option<u64>,
297) -> String {
298 let id_marker = format!("id=\"{mapping_id}\"");
299 let Some(mapping_start) = content.find(&id_marker) else {
300 return content.to_string();
301 };
302 let Some(rel_end) = content[mapping_start..].find("</art:mapping>") else {
303 return content.to_string();
304 };
305 let mapping_end = mapping_start + rel_end + "</art:mapping>".len();
306
307 let block = &content[mapping_start..mapping_end];
308 let old_attr = format!(" {attr_name}=\"{old_hash}\"");
310 let new_attr = format!(" {attr_name}=\"{new_hash}\"");
311
312 if let (Some(sl), Some(_el)) = (start_line, end_line) {
313 let anchor = format!("start-line=\"{sl}\"");
315 if let Some(anchor_pos) = block.find(&anchor) {
316 let tag_start = block[..anchor_pos]
317 .rfind("<art:range")
318 .unwrap_or(anchor_pos);
319 let tag_end = block[anchor_pos..]
320 .find("/>")
321 .map_or(block.len(), |p| anchor_pos + p + 2);
322 let tag = &block[tag_start..tag_end];
323
324 if tag.contains(&old_attr) {
325 let new_tag = tag.replace(&old_attr, &new_attr);
326 let abs_start = mapping_start + tag_start;
327 let abs_end = mapping_start + tag_end;
328 return format!(
329 "{}{}{}",
330 &content[..abs_start],
331 new_tag,
332 &content[abs_end..]
333 );
334 }
335 }
336 } else if let Some(pos) = block.find(&old_attr) {
337 let abs_pos = mapping_start + pos;
339 return format!(
340 "{}{}{}",
341 &content[..abs_pos],
342 new_attr,
343 &content[abs_pos + old_attr.len()..]
344 );
345 }
346
347 content.to_string()
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn replace_hash_node_hash() {
356 let content = r#"<art:mapping id="map-foo">
357 <art:spec-ref node="foo" node-hash="sha256:oldoldold"/>
358</art:mapping>"#;
359 let result = replace_hash_in_mapping(
360 content,
361 "map-foo",
362 "node-hash",
363 "sha256:oldoldold",
364 "sha256:newnewnew",
365 None,
366 None,
367 );
368 assert!(result.contains("node-hash=\"sha256:newnewnew\""));
369 assert!(!result.contains("sha256:oldoldold"));
370 }
371
372 #[test]
373 fn replace_hash_range_with_line_anchor() {
374 let content = r#"<art:mapping id="map-bar">
375 <art:spec-ref node="bar" node-hash="sha256:aaa"/>
376 <art:artifact path="src/foo.rs">
377 <art:range hash="sha256:old1" start-line="10" end-line="20"/>
378 <art:range hash="sha256:old2" start-line="30" end-line="40"/>
379 </art:artifact>
380</art:mapping>"#;
381 let result = replace_hash_in_mapping(
383 content,
384 "map-bar",
385 "hash",
386 "sha256:old2",
387 "sha256:new2",
388 Some(30),
389 Some(40),
390 );
391 assert!(result.contains("hash=\"sha256:old1\""), "first range should be unchanged");
392 assert!(result.contains("hash=\"sha256:new2\""), "second range should be updated");
393 }
394
395 #[test]
396 fn replace_hash_whole_file_range() {
397 let content = r#"<art:mapping id="map-baz">
398 <art:spec-ref node="baz" node-hash="sha256:aaa"/>
399 <art:artifact path="README.md">
400 <art:range hash="sha256:old"/>
401 </art:artifact>
402</art:mapping>"#;
403 let result = replace_hash_in_mapping(
404 content,
405 "map-baz",
406 "hash",
407 "sha256:old",
408 "sha256:new",
409 None,
410 None,
411 );
412 assert!(result.contains("hash=\"sha256:new\""));
413 }
414
415 #[test]
416 fn replace_does_not_affect_other_mappings() {
417 let content = r#"<art:mapping id="map-a">
418 <art:spec-ref node="a" node-hash="sha256:same"/>
419</art:mapping>
420<art:mapping id="map-b">
421 <art:spec-ref node="b" node-hash="sha256:same"/>
422</art:mapping>"#;
423 let result = replace_hash_in_mapping(
424 content,
425 "map-a",
426 "node-hash",
427 "sha256:same",
428 "sha256:changed",
429 None,
430 None,
431 );
432 let pos_a = result.find("map-a").unwrap();
434 let pos_b = result.find("map-b").unwrap();
435 let between = &result[pos_a..pos_b];
436 assert!(between.contains("sha256:changed"));
437 let after_b = &result[pos_b..];
438 assert!(after_b.contains("sha256:same"));
439 }
440
441 #[test]
442 fn replace_hash_missing_mapping_returns_unchanged() {
443 let content = r#"<art:mapping id="map-exists">
444 <art:spec-ref node="x" node-hash="sha256:aaa"/>
445</art:mapping>"#;
446 let result = replace_hash_in_mapping(
447 content,
448 "map-nonexistent",
449 "node-hash",
450 "sha256:aaa",
451 "sha256:bbb",
452 None,
453 None,
454 );
455 assert_eq!(result, content, "content should be unchanged for missing mapping");
456 }
457
458 #[test]
459 fn replace_hash_missing_attribute_returns_unchanged() {
460 let content = r#"<art:mapping id="map-foo">
461 <art:spec-ref node="foo" node-hash="sha256:aaa"/>
462</art:mapping>"#;
463 let result = replace_hash_in_mapping(
465 content,
466 "map-foo",
467 "node-hash",
468 "sha256:nonexistent",
469 "sha256:bbb",
470 None,
471 None,
472 );
473 assert_eq!(result, content, "content should be unchanged when old_hash not found");
474 }
475
476 #[test]
477 fn replace_hash_first_range_with_line_anchor() {
478 let content = r#"<art:mapping id="map-bar">
479 <art:spec-ref node="bar" node-hash="sha256:aaa"/>
480 <art:artifact path="src/foo.rs">
481 <art:range hash="sha256:old1" start-line="10" end-line="20"/>
482 <art:range hash="sha256:old2" start-line="30" end-line="40"/>
483 </art:artifact>
484</art:mapping>"#;
485 let result = replace_hash_in_mapping(
487 content,
488 "map-bar",
489 "hash",
490 "sha256:old1",
491 "sha256:new1",
492 Some(10),
493 Some(20),
494 );
495 assert!(result.contains("hash=\"sha256:new1\""), "first range should be updated");
496 assert!(result.contains("hash=\"sha256:old2\""), "second range should be unchanged");
497 }
498
499 #[test]
500 fn replace_preserves_surrounding_content() {
501 let content = r#"<?xml version="1.0"?>
502<spec:clayers xmlns:art="urn:clayers:artifact">
503 <art:mapping id="map-test">
504 <art:spec-ref node="test" node-hash="sha256:old"/>
505 </art:mapping>
506 <!-- trailing content -->
507</spec:clayers>"#;
508 let result = replace_hash_in_mapping(
509 content,
510 "map-test",
511 "node-hash",
512 "sha256:old",
513 "sha256:new",
514 None,
515 None,
516 );
517 assert!(result.starts_with("<?xml version=\"1.0\"?>"));
518 assert!(result.contains("<!-- trailing content -->"));
519 assert!(result.contains("</spec:clayers>"));
520 assert!(result.contains("node-hash=\"sha256:new\""));
521 }
522
523 fn create_synthetic_spec(dir: &Path, artifact_content: &str) {
526 let index = r#"<?xml version="1.0" encoding="UTF-8"?>
528<idx:index xmlns:idx="urn:clayers:index">
529 <idx:file href="content.xml"/>
530</idx:index>"#;
531 std::fs::write(dir.join("index.xml"), index).unwrap();
532
533 let content = r#"<?xml version="1.0" encoding="UTF-8"?>
535<spec:clayers xmlns:spec="urn:clayers:spec"
536 xmlns:pr="urn:clayers:prose"
537 xmlns:art="urn:clayers:artifact"
538 spec:index="index.xml">
539 <pr:section id="test-node">
540 <pr:title>Test Node</pr:title>
541 <pr:p>Some content here.</pr:p>
542 </pr:section>
543 <art:mapping id="map-test">
544 <art:spec-ref node="test-node" node-hash="sha256:placeholder"/>
545 <art:artifact repo="test" path="artifact.txt">
546 <art:range hash="sha256:placeholder"/>
547 </art:artifact>
548 </art:mapping>
549</spec:clayers>"#;
550 std::fs::write(dir.join("content.xml"), content).unwrap();
551
552 std::fs::write(dir.join("artifact.txt"), artifact_content).unwrap();
554 }
555
556 fn create_synthetic_spec_with_ranges(dir: &Path) {
557 let index = r#"<?xml version="1.0" encoding="UTF-8"?>
558<idx:index xmlns:idx="urn:clayers:index">
559 <idx:file href="content.xml"/>
560</idx:index>"#;
561 std::fs::write(dir.join("index.xml"), index).unwrap();
562
563 let content = r#"<?xml version="1.0" encoding="UTF-8"?>
564<spec:clayers xmlns:spec="urn:clayers:spec"
565 xmlns:pr="urn:clayers:prose"
566 xmlns:art="urn:clayers:artifact"
567 spec:index="index.xml">
568 <pr:section id="test-node">
569 <pr:title>Test Node</pr:title>
570 <pr:p>Content.</pr:p>
571 </pr:section>
572 <art:mapping id="map-ranges">
573 <art:spec-ref node="test-node" node-hash="sha256:placeholder"/>
574 <art:artifact repo="test" path="code.rs">
575 <art:range hash="sha256:placeholder" start-line="2" end-line="4"/>
576 <art:range hash="sha256:placeholder" start-line="6" end-line="8"/>
577 </art:artifact>
578 </art:mapping>
579</spec:clayers>"#;
580 std::fs::write(dir.join("content.xml"), content).unwrap();
581
582 let code = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n";
583 std::fs::write(dir.join("code.rs"), code).unwrap();
584 }
585
586 #[test]
587 fn fix_artifact_hashes_updates_whole_file_hash() {
588 let tmp = tempfile::tempdir().expect("tempdir");
589 create_synthetic_spec(tmp.path(), "hello world\n");
590
591 let report = fix_artifact_hashes(tmp.path()).expect("fix failed");
592 assert_eq!(report.total_mappings, 1);
593 assert_eq!(report.fixed_count, 1);
594 assert_eq!(report.results[0].mapping_id, "map-test");
595 assert_eq!(report.results[0].old_hash, "sha256:placeholder");
596 assert!(
597 report.results[0].new_hash.starts_with("sha256:"),
598 "new hash should be sha256 prefixed"
599 );
600 assert_ne!(report.results[0].new_hash, "sha256:placeholder");
601
602 let xml = std::fs::read_to_string(tmp.path().join("content.xml")).unwrap();
604 let new_hash = &report.results[0].new_hash;
605 assert!(
606 xml.contains(&format!("hash=\"{new_hash}\"")),
607 "artifact range hash should be updated on disk"
608 );
609 assert!(
611 xml.contains("node-hash=\"sha256:placeholder\""),
612 "node-hash should remain unchanged"
613 );
614 }
615
616 #[test]
617 fn fix_artifact_hashes_idempotent() {
618 let tmp = tempfile::tempdir().expect("tempdir");
619 create_synthetic_spec(tmp.path(), "hello world\n");
620
621 let first = fix_artifact_hashes(tmp.path()).expect("first fix failed");
622 assert_eq!(first.fixed_count, 1);
623
624 let second = fix_artifact_hashes(tmp.path()).expect("second fix failed");
626 assert_eq!(second.fixed_count, 0, "second run should be a no-op");
627 }
628
629 #[test]
630 fn fix_artifact_hashes_with_line_ranges() {
631 let tmp = tempfile::tempdir().expect("tempdir");
632 create_synthetic_spec_with_ranges(tmp.path());
633
634 let report = fix_artifact_hashes(tmp.path()).expect("fix failed");
635 assert_eq!(report.total_mappings, 1);
636 assert_eq!(report.fixed_count, 2);
638
639 let hashes: Vec<&str> = report.results.iter().map(|r| r.new_hash.as_str()).collect();
641 assert_ne!(hashes[0], hashes[1], "different line ranges should produce different hashes");
642
643 let xml = std::fs::read_to_string(tmp.path().join("content.xml")).unwrap();
645 for h in &hashes {
646 assert!(xml.contains(h), "range hash {h} should appear on disk");
647 }
648 assert!(xml.contains("node-hash=\"sha256:placeholder\""));
650 }
651
652 #[test]
653 fn fix_artifact_hashes_skips_missing_files() {
654 let tmp = tempfile::tempdir().expect("tempdir");
655 create_synthetic_spec(tmp.path(), "content");
656
657 std::fs::remove_file(tmp.path().join("artifact.txt")).unwrap();
659
660 let report = fix_artifact_hashes(tmp.path()).expect("fix should succeed");
661 assert_eq!(report.total_mappings, 1);
662 assert_eq!(report.fixed_count, 0, "should skip missing artifact files");
663 }
664
665 #[test]
666 fn fix_node_hashes_updates_hash() {
667 let tmp = tempfile::tempdir().expect("tempdir");
668 create_synthetic_spec(tmp.path(), "irrelevant");
669
670 let report = fix_node_hashes(tmp.path()).expect("fix failed");
671 assert_eq!(report.total_mappings, 1);
672 assert_eq!(report.fixed_count, 1);
673 assert_eq!(report.results[0].mapping_id, "map-test");
674 assert!(report.results[0].new_hash.starts_with("sha256:"));
675 assert_ne!(report.results[0].new_hash, "sha256:placeholder");
676
677 let xml = std::fs::read_to_string(tmp.path().join("content.xml")).unwrap();
679 assert!(!xml.contains("node-hash=\"sha256:placeholder\""));
680 assert!(xml.contains(&format!("node-hash=\"{}\"", report.results[0].new_hash)));
681 }
682
683 #[test]
684 fn fix_node_hashes_idempotent() {
685 let tmp = tempfile::tempdir().expect("tempdir");
686 create_synthetic_spec(tmp.path(), "irrelevant");
687
688 let first = fix_node_hashes(tmp.path()).expect("first fix failed");
689 assert_eq!(first.fixed_count, 1);
690
691 let second = fix_node_hashes(tmp.path()).expect("second fix failed");
692 assert_eq!(second.fixed_count, 0, "second run should be a no-op");
693 }
694
695 #[test]
696 fn fix_node_hashes_deterministic() {
697 let tmp1 = tempfile::tempdir().expect("tempdir1");
699 let tmp2 = tempfile::tempdir().expect("tempdir2");
700 create_synthetic_spec(tmp1.path(), "a");
701 create_synthetic_spec(tmp2.path(), "b"); let r1 = fix_node_hashes(tmp1.path()).expect("fix1 failed");
704 let r2 = fix_node_hashes(tmp2.path()).expect("fix2 failed");
705
706 assert_eq!(
707 r1.results[0].new_hash, r2.results[0].new_hash,
708 "same spec node content should produce same node hash regardless of artifact content"
709 );
710 }
711
712 #[test]
713 fn fix_artifact_hashes_different_content_different_hash() {
714 let tmp1 = tempfile::tempdir().expect("tempdir1");
715 let tmp2 = tempfile::tempdir().expect("tempdir2");
716 create_synthetic_spec(tmp1.path(), "content A\n");
717 create_synthetic_spec(tmp2.path(), "content B\n");
718
719 let r1 = fix_artifact_hashes(tmp1.path()).expect("fix1 failed");
720 let r2 = fix_artifact_hashes(tmp2.path()).expect("fix2 failed");
721
722 assert_ne!(
723 r1.results[0].new_hash, r2.results[0].new_hash,
724 "different file content should produce different hashes"
725 );
726 }
727
728 #[test]
729 fn fix_both_hashes_on_synthetic_spec() {
730 let tmp = tempfile::tempdir().expect("tempdir");
731 create_synthetic_spec(tmp.path(), "test content\n");
732
733 let node_report = fix_node_hashes(tmp.path()).expect("fix_node failed");
735 assert_eq!(node_report.fixed_count, 1);
736
737 let art_report = fix_artifact_hashes(tmp.path()).expect("fix_artifact failed");
738 assert_eq!(art_report.fixed_count, 1);
739
740 let xml = std::fs::read_to_string(tmp.path().join("content.xml")).unwrap();
742 assert!(!xml.contains("sha256:placeholder"));
743
744 let node2 = fix_node_hashes(tmp.path()).expect("fix_node2 failed");
746 let art2 = fix_artifact_hashes(tmp.path()).expect("fix_artifact2 failed");
747 assert_eq!(node2.fixed_count, 0);
748 assert_eq!(art2.fixed_count, 0);
749 }
750
751 #[test]
752 fn fix_artifact_hashes_on_shipped_spec() {
753 let spec_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
754 .join("../../clayers/clayers")
755 .canonicalize()
756 .expect("clayers/clayers/ not found");
757
758 let tmp = tempfile::tempdir().expect("tempdir");
759 copy_dir_all(&spec_dir, tmp.path()).expect("copy");
760
761 let report = fix_artifact_hashes(tmp.path()).expect("fix_artifact_hashes failed");
762 assert!(
763 report.total_mappings > 0,
764 "should find mappings in copied spec"
765 );
766 }
767
768 #[test]
769 fn fix_node_hashes_on_shipped_spec() {
770 let spec_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
771 .join("../../clayers/clayers")
772 .canonicalize()
773 .expect("clayers/clayers/ not found");
774
775 let tmp = tempfile::tempdir().expect("tempdir");
776 copy_dir_all(&spec_dir, tmp.path()).expect("copy");
777
778 let report = fix_node_hashes(tmp.path()).expect("fix_node_hashes failed");
779 assert!(
780 report.total_mappings > 0,
781 "should find mappings in copied spec"
782 );
783 }
784
785 #[test]
786 fn fix_node_hashes_shipped_spec_idempotent() {
787 let spec_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
788 .join("../../clayers/clayers")
789 .canonicalize()
790 .expect("clayers/clayers/ not found");
791
792 let tmp = tempfile::tempdir().expect("tempdir");
793 copy_dir_all(&spec_dir, tmp.path()).expect("copy");
794
795 let first = fix_node_hashes(tmp.path()).expect("first fix failed");
798 assert!(first.total_mappings > 0, "should find mappings");
799
800 let second = fix_node_hashes(tmp.path()).expect("second fix failed");
802 assert_eq!(
803 second.fixed_count, 0,
804 "second run should find nothing to fix (idempotent)"
805 );
806 }
807
808 fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
809 std::fs::create_dir_all(dst)?;
810 for entry in std::fs::read_dir(src)? {
811 let entry = entry?;
812 let ty = entry.file_type()?;
813 let dest_path = dst.join(entry.file_name());
814 if ty.is_dir() {
815 copy_dir_all(&entry.path(), &dest_path)?;
816 } else {
817 std::fs::copy(entry.path(), &dest_path)?;
818 }
819 }
820 Ok(())
821 }
822}