1use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25
26use super::cursor::enumerate_facet_files;
27use super::resolve::{ResolvedIngest, ResolvedSource};
28
29pub const ROTATION_UNCOVERED_FILES: &str = "uncovered-files";
33
34pub const ROTATION_ANCHOR_ADJUDICATION: &str = "anchor-adjudication";
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44struct RotationCursor {
45 #[serde(default)]
46 rotation: u64,
47 #[serde(default)]
48 cursor: usize,
49 #[serde(default)]
50 order: Vec<String>,
51}
52
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62struct RefinementState {
63 #[serde(default)]
68 verify_runs: u64,
69 #[serde(default)]
72 rotations: BTreeMap<String, RotationCursor>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Batch {
78 pub files: Vec<String>,
80 pub rotation: u64,
82 pub batch_index: usize,
84 pub total_batches: usize,
86}
87
88fn refinement_dir(cache_root: &Path) -> PathBuf {
90 cache_root.join("refinement")
91}
92
93fn state_path(cache_root: &Path, binding_name: &str) -> PathBuf {
94 refinement_dir(cache_root).join(format!("{binding_name}.json"))
95}
96
97fn enumerate_source_files(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
102 let mut files: Vec<String> = Vec::new();
103 for source in &resolved.sources {
104 if let ResolvedSource::Primary(p) = source {
105 files.extend(enumerate_facet_files(
106 p,
107 &resolved.deny_paths,
108 workspace_root,
109 ));
110 }
111 }
112 files.sort();
113 files.dedup();
114 files
115}
116
117fn shuffle(files: &mut [String], seed: u64) {
119 let mut state = seed
120 .wrapping_mul(6_364_136_223_846_793_005)
121 .wrapping_add(1_442_695_040_888_963_407);
122 for i in (1..files.len()).rev() {
123 state = state
124 .wrapping_mul(6_364_136_223_846_793_005)
125 .wrapping_add(1_442_695_040_888_963_407);
126 let j = ((state >> 33) as usize) % (i + 1);
127 files.swap(i, j);
128 }
129}
130
131fn load_state(cache_root: &Path, binding_name: &str) -> Option<RefinementState> {
132 let bytes = std::fs::read(state_path(cache_root, binding_name)).ok()?;
133 serde_json::from_slice(&bytes).ok()
134}
135
136fn save_state(cache_root: &Path, binding_name: &str, state: &RefinementState) {
137 let path = state_path(cache_root, binding_name);
138 if let Some(parent) = path.parent() {
139 let _ = std::fs::create_dir_all(parent);
140 }
141 if let Ok(mut bytes) = serde_json::to_vec_pretty(state) {
142 bytes.push(b'\n');
143 let _ = std::fs::write(path, bytes);
144 }
145}
146
147pub fn bump_verify_runs(cache_root: &Path, binding_name: &str) -> u64 {
154 let mut state = load_state(cache_root, binding_name).unwrap_or_default();
155 state.verify_runs = state.verify_runs.saturating_add(1);
156 let n = state.verify_runs;
157 save_state(cache_root, binding_name, &state);
158 n
159}
160
161pub fn next_rotation_batch(
169 cache_root: &Path,
170 binding_name: &str,
171 rotation_key: &str,
172 items: Vec<String>,
173 batch_size: usize,
174) -> Option<Batch> {
175 let batch_size = batch_size.max(1);
176 if items.is_empty() {
177 return None;
178 }
179
180 let mut state = load_state(cache_root, binding_name).unwrap_or_default();
181 let mut cursor = state.rotations.remove(rotation_key).unwrap_or_default();
182 if cursor.order.is_empty() || cursor.cursor >= cursor.order.len() {
183 let rotation = cursor.rotation + u64::from(!cursor.order.is_empty());
186 let mut order = items;
187 shuffle(&mut order, rotation);
188 cursor = RotationCursor {
189 rotation,
190 cursor: 0,
191 order,
192 };
193 }
194
195 let end = (cursor.cursor + batch_size).min(cursor.order.len());
196 let files = cursor.order[cursor.cursor..end].to_vec();
197 let batch_index = cursor.cursor / batch_size + 1;
198 let total_batches = cursor.order.len().div_ceil(batch_size);
199 cursor.cursor += files.len();
200 let rotation = cursor.rotation;
201 state.rotations.insert(rotation_key.to_string(), cursor);
202 save_state(cache_root, binding_name, &state);
203
204 Some(Batch {
205 files,
206 rotation,
207 batch_index,
208 total_batches,
209 })
210}
211
212pub fn next_batch(
217 resolved: &ResolvedIngest,
218 workspace_root: &Path,
219 cache_root: &Path,
220 batch_size: usize,
221) -> Option<Batch> {
222 let all_files = enumerate_source_files(resolved, workspace_root);
223 next_rotation_batch(
224 cache_root,
225 &resolved.name,
226 ROTATION_UNCOVERED_FILES,
227 all_files,
228 batch_size,
229 )
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use crate::binding::BuildMode;
236 use crate::ingest::resolve::Source;
237 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
238
239 fn resolved(name: &str, batch_size: u32) -> ResolvedIngest {
240 ResolvedIngest {
241 name: name.to_string(),
242 mode: BuildMode::Discovery,
243 trigger: IngestTrigger::Loop,
244 batch_size,
245 deny_paths: vec![],
246 projection_ref: format!("{name}/p"),
247 projection_mem: name.to_string(),
248 projection_name: "p".to_string(),
249 intent: None,
250 sources: vec![ResolvedSource::Primary(Source {
251 name: "f".to_string(),
252 medium_type: MediumType::Codebase,
253 pointer: String::new(),
254 change_detection: None,
255 scope: vec![PatternEntry {
256 path: "**/*.rs".to_string(),
257 mode: PatternMode::Allow,
258 }],
259 engagement: None,
260 preparation: None,
261 })],
262 destination_mem: name.to_string(),
263 rules: None,
264 post_actions: None,
265 }
266 }
267
268 #[test]
271 fn next_batch_walks_a_rotation_then_starts_a_new_one() {
272 let ws = tempfile::tempdir().unwrap();
273 let cache = tempfile::tempdir().unwrap();
274 let root = ws.path();
275 for i in 0..5 {
276 std::fs::write(root.join(format!("f{i}.rs")), "").unwrap();
277 }
278 let r = resolved("ref", 2);
279
280 let b1 = next_batch(&r, root, cache.path(), 2).unwrap();
281 assert_eq!(b1.rotation, 0);
282 assert_eq!(b1.batch_index, 1);
283 assert_eq!(b1.total_batches, 3); assert_eq!(b1.files.len(), 2);
285
286 let b2 = next_batch(&r, root, cache.path(), 2).unwrap();
287 assert_eq!(b2.batch_index, 2);
288 let b3 = next_batch(&r, root, cache.path(), 2).unwrap();
289 assert_eq!(b3.batch_index, 3);
290 assert_eq!(b3.files.len(), 1); let b4 = next_batch(&r, root, cache.path(), 2).unwrap();
294 assert_eq!(b4.rotation, 1);
295 assert_eq!(b4.batch_index, 1);
296
297 let mut seen: Vec<String> = [b1.files, b2.files, b3.files].concat();
299 seen.sort();
300 seen.dedup();
301 assert_eq!(seen.len(), 5, "the rotation covers all files");
302 }
303
304 #[test]
308 fn named_rotation_is_deterministic_and_covers_the_whole_set() {
309 let cache = tempfile::tempdir().unwrap();
310 let items: Vec<String> = (0..6).map(|i| format!("id{i}")).collect();
311 let key = ROTATION_ANCHOR_ADJUDICATION;
312
313 let mut covered: Vec<String> = Vec::new();
315 let mut order_r0: Vec<String> = Vec::new();
316 for i in 0..3 {
317 let b = next_rotation_batch(cache.path(), "m/b", key, items.clone(), 2).unwrap();
318 assert_eq!(b.rotation, 0);
319 assert_eq!(b.batch_index, i + 1);
320 assert_eq!(b.total_batches, 3);
321 covered.extend(b.files.clone());
322 order_r0.extend(b.files);
323 }
324 let mut uniq = covered.clone();
325 uniq.sort();
326 uniq.dedup();
327 assert_eq!(uniq.len(), 6, "one rotation covers the whole set");
328
329 let b = next_rotation_batch(cache.path(), "m/b", key, items.clone(), 2).unwrap();
331 assert_eq!(
332 b.rotation, 1,
333 "a new rotation starts once the prior is done"
334 );
335
336 let cache2 = tempfile::tempdir().unwrap();
339 let mut order_repro: Vec<String> = Vec::new();
340 for _ in 0..3 {
341 let b = next_rotation_batch(cache2.path(), "m/b", key, items.clone(), 2).unwrap();
342 order_repro.extend(b.files);
343 }
344 assert_eq!(order_r0, order_repro, "same seed/state → same sequence");
345 }
346
347 #[test]
350 fn named_rotations_are_independent() {
351 let cache = tempfile::tempdir().unwrap();
352 let a: Vec<String> = (0..4).map(|i| format!("a{i}")).collect();
353 let files =
354 next_rotation_batch(cache.path(), "m/b", ROTATION_UNCOVERED_FILES, a.clone(), 2)
355 .unwrap();
356 let anchors = next_rotation_batch(
357 cache.path(),
358 "m/b",
359 ROTATION_ANCHOR_ADJUDICATION,
360 a.clone(),
361 2,
362 )
363 .unwrap();
364 assert_eq!(files.batch_index, 1);
366 assert_eq!(anchors.batch_index, 1);
367 let files2 =
369 next_rotation_batch(cache.path(), "m/b", ROTATION_UNCOVERED_FILES, a.clone(), 2)
370 .unwrap();
371 assert_eq!(files2.batch_index, 2);
372 let anchors_again =
373 next_rotation_batch(cache.path(), "m/b", ROTATION_ANCHOR_ADJUDICATION, a, 2).unwrap();
374 assert_eq!(anchors_again.batch_index, 2, "anchor cursor is independent");
375 }
376
377 #[test]
380 fn verify_run_counter_ticks_and_persists() {
381 let cache = tempfile::tempdir().unwrap();
382 assert_eq!(bump_verify_runs(cache.path(), "m/b"), 1);
383 assert_eq!(bump_verify_runs(cache.path(), "m/b"), 2);
384 assert_eq!(bump_verify_runs(cache.path(), "m/b"), 3);
385 assert_eq!(bump_verify_runs(cache.path(), "m/other"), 1);
387 }
388}