Skip to main content

clayers_spec/
fix.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use clayers_xml::c14n;
5
6use crate::artifact;
7
8/// Result of a single hash fix operation.
9#[derive(Debug)]
10pub struct FixResult {
11    pub mapping_id: String,
12    pub old_hash: String,
13    pub new_hash: String,
14}
15
16/// Report from a fix operation.
17#[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
25/// Fix artifact-side hashes by recomputing from current file content.
26///
27/// For each artifact mapping, reads the referenced file (with line-range
28/// addressing if specified), computes SHA-256, and updates the `hash`
29/// attribute on `<art:range>` elements in-place.
30///
31/// # Errors
32///
33/// Returns an error if spec files cannot be read or artifact files are missing.
34pub 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
109/// Fix node-side hashes by recomputing C14N hash from current spec content.
110///
111/// Assembles the combined document, finds each referenced spec node,
112/// serializes it, applies inclusive C14N via bergshamra, and computes
113/// SHA-256. Updates `node-hash` attributes on `<art:spec-ref>` in-place.
114///
115/// # Errors
116///
117/// Returns an error if spec files cannot be read or assembled.
118pub 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
196// --- Internal types ---
197
198struct 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
212// --- Helpers ---
213
214fn 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        // Check bare @id
223        if xot.get_attribute(node, id_attr)
224            .is_some_and(|id| id == target_id)
225        {
226            return Some(node);
227        }
228        // Check xml:id
229        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
284/// Replace a hash attribute value within a specific mapping block.
285///
286/// Uses the mapping ID as an anchor to locate the correct mapping block,
287/// then uses `start-line`/`end-line` attributes (if present) to anchor
288/// the replacement to the correct `<art:range>` element.
289fn 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    // Use a space boundary to avoid matching "node-hash" when attr_name is "hash"
309    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        // Find the range tag anchored by start-line/end-line
314        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        // No line anchor: replace the first occurrence of the attribute in the block
338        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        // Replace only the second range
382        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        // map-a should be changed, map-b should be untouched
433        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        // Try to replace a hash that doesn't exist in this mapping
464        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        // Replace only the first range
486        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    // --- Synthetic spec helpers ---
524
525    fn create_synthetic_spec(dir: &Path, artifact_content: &str) {
526        // Create index.xml
527        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        // Create content.xml with an artifact mapping
534        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        // Create the artifact file
553        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        // Verify the artifact hash was updated on disk (node-hash is not touched)
603        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        // node-hash is untouched by fix_artifact_hashes
610        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        // Second run should find nothing to fix
625        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        // Two ranges, both with placeholder hashes
637        assert_eq!(report.fixed_count, 2);
638
639        // The two ranges should have different hashes (different content)
640        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        // Verify range hashes updated on disk
644        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        // node-hash is untouched
649        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        // Delete the artifact file
658        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        // Verify the XML was updated on disk
678        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        // Create two identical specs and verify they produce the same node hash
698        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"); // different artifact, same spec content
702
703        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        // Fix node hashes first, then artifact hashes
734        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        // Verify both hashes are now real (not placeholder)
741        let xml = std::fs::read_to_string(tmp.path().join("content.xml")).unwrap();
742        assert!(!xml.contains("sha256:placeholder"));
743
744        // Both should be idempotent now
745        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        // First run computes hashes (may or may not change depending on
796        // whether the shipped spec has correct hashes already)
797        let first = fix_node_hashes(tmp.path()).expect("first fix failed");
798        assert!(first.total_mappings > 0, "should find mappings");
799
800        // Second run must be a no-op regardless
801        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}