1use std::path::Path;
2
3use crate::artifact::{self, ArtifactMapping};
4
5#[derive(Debug)]
7pub enum DriftStatus {
8 Clean,
10 SpecDrifted {
12 stored_hash: String,
13 current_hash: String,
14 },
15 ArtifactDrifted {
17 stored_hash: String,
18 current_hash: String,
19 artifact_path: String,
20 },
21 Unavailable { reason: String },
23}
24
25#[derive(Debug)]
27pub struct MappingDrift {
28 pub mapping_id: String,
29 pub status: DriftStatus,
30}
31
32#[derive(Debug)]
34pub struct DriftReport {
35 pub spec_name: String,
36 pub total_mappings: usize,
37 pub drifted_count: usize,
38 pub mapping_drifts: Vec<MappingDrift>,
39}
40
41pub fn check_drift(spec_dir: &Path, repo_root: Option<&Path>) -> Result<DriftReport, crate::Error> {
50 let index_files = crate::discovery::find_index_files(spec_dir)?;
51 let spec_name = spec_dir
52 .file_name()
53 .map_or_else(|| "unknown".into(), |n| n.to_string_lossy().into_owned());
54
55 let mut all_mappings = Vec::new();
56 let mut all_file_paths = Vec::new();
57
58 for index_path in &index_files {
59 let file_paths = crate::discovery::discover_spec_files(index_path)?;
60 let mappings = artifact::collect_artifact_mappings(&file_paths)?;
61 all_mappings.extend(mappings);
62 all_file_paths.extend(file_paths);
63 }
64
65 let nodes = collect_spec_node_ids(&all_file_paths)?;
67
68 let mut mapping_drifts = Vec::new();
69 let mut drifted_count = 0;
70
71 for mapping in &all_mappings {
72 let drift = check_single_mapping(mapping, &nodes, repo_root, spec_dir);
73 if matches!(
74 drift.status,
75 DriftStatus::SpecDrifted { .. } | DriftStatus::ArtifactDrifted { .. }
76 ) {
77 drifted_count += 1;
78 }
79 mapping_drifts.push(drift);
80 }
81
82 Ok(DriftReport {
83 spec_name,
84 total_mappings: all_mappings.len(),
85 drifted_count,
86 mapping_drifts,
87 })
88}
89
90fn check_single_mapping(
91 mapping: &ArtifactMapping,
92 nodes: &std::collections::HashMap<String, xot::Node>,
93 repo_root: Option<&Path>,
94 spec_dir: &Path,
95) -> MappingDrift {
96 let id = mapping.id.clone();
97
98 if let Some(ref stored_hash) = mapping.node_hash {
100 if !stored_hash.starts_with("sha256:") || stored_hash == "sha256:placeholder" {
101 } else if let Some(&_node) = nodes.get(&mapping.spec_ref_node) {
103 }
106 }
107
108 for range in &mapping.ranges {
110 if let Some(ref stored_hash) = range.hash {
111 if !stored_hash.starts_with("sha256:") || stored_hash == "sha256:placeholder" {
112 continue;
113 }
114
115 let artifact_path =
116 artifact::resolve_artifact_path(&mapping.artifact_path, spec_dir, repo_root);
117
118 if !artifact_path.exists() {
119 return MappingDrift {
120 mapping_id: id,
121 status: DriftStatus::Unavailable {
122 reason: format!("artifact file not found: {}", mapping.artifact_path),
123 },
124 };
125 }
126
127 let current_hash_result =
128 if let (Some(start), Some(end)) = (range.start_line, range.end_line) {
129 artifact::hash_line_range(&artifact_path, start, end)
130 } else {
131 artifact::hash_file(&artifact_path)
132 };
133
134 match current_hash_result {
135 Ok(current_hash) => {
136 let current_str = current_hash.to_prefixed();
137 if ¤t_str != stored_hash {
138 return MappingDrift {
139 mapping_id: id,
140 status: DriftStatus::ArtifactDrifted {
141 stored_hash: stored_hash.clone(),
142 current_hash: current_str,
143 artifact_path: mapping.artifact_path.clone(),
144 },
145 };
146 }
147 }
148 Err(e) => {
149 return MappingDrift {
150 mapping_id: id,
151 status: DriftStatus::Unavailable {
152 reason: format!("hash computation failed: {e}"),
153 },
154 };
155 }
156 }
157 }
158 }
159
160 MappingDrift {
161 mapping_id: id,
162 status: DriftStatus::Clean,
163 }
164}
165
166fn collect_spec_node_ids(
167 file_paths: &[impl AsRef<Path>],
168) -> Result<std::collections::HashMap<String, xot::Node>, crate::Error> {
169 let mut nodes = std::collections::HashMap::new();
170 for file_path in file_paths {
173 let content = std::fs::read_to_string(file_path.as_ref())?;
174 let mut xot = xot::Xot::new();
175 let doc = xot.parse(&content).map_err(xot::Error::from)?;
176 let root = xot.document_element(doc)?;
177 let id_attr = xot.add_name("id");
178 collect_nodes_recursive(&xot, root, id_attr, &mut nodes);
179 }
180 Ok(nodes)
181}
182
183fn collect_nodes_recursive(
184 xot: &xot::Xot,
185 node: xot::Node,
186 id_attr: xot::NameId,
187 nodes: &mut std::collections::HashMap<String, xot::Node>,
188) {
189 let _ = xot.get_attribute(node, id_attr);
193 let _ = &nodes;
194 for child in xot.children(node) {
195 collect_nodes_recursive(xot, child, id_attr, nodes);
196 }
197}
198
199#[must_use]
201pub fn hashes_match(stored: &str, current: &str) -> bool {
202 stored == current
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn identical_hashes_no_drift() {
211 assert!(hashes_match(
212 "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
213 "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
214 ));
215 }
216
217 #[test]
218 fn different_hashes_drift_detected() {
219 assert!(!hashes_match(
220 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
221 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
222 ));
223 }
224
225 #[test]
226 fn drift_report_on_shipped_spec() {
227 let spec_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
228 .join("../../clayers/clayers")
229 .canonicalize()
230 .expect("clayers/clayers/ not found");
231 let report = check_drift(&spec_dir, None).expect("drift check failed");
232 assert!(
234 report.total_mappings > 0,
235 "shipped spec should have artifact mappings"
236 );
237 }
238}