use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::cursor::enumerate_facet_files;
use super::resolve::{ResolvedIngest, ResolvedSource};
pub const ROTATION_UNCOVERED_FILES: &str = "uncovered-files";
pub const ROTATION_ANCHOR_ADJUDICATION: &str = "anchor-adjudication";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct RotationCursor {
#[serde(default)]
rotation: u64,
#[serde(default)]
cursor: usize,
#[serde(default)]
order: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct RefinementState {
#[serde(default)]
verify_runs: u64,
#[serde(default)]
rotations: BTreeMap<String, RotationCursor>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Batch {
pub files: Vec<String>,
pub rotation: u64,
pub batch_index: usize,
pub total_batches: usize,
}
fn refinement_dir(cache_root: &Path) -> PathBuf {
cache_root.join("refinement")
}
fn state_path(cache_root: &Path, binding_name: &str) -> PathBuf {
refinement_dir(cache_root).join(format!("{binding_name}.json"))
}
fn enumerate_source_files(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
let mut files: Vec<String> = Vec::new();
for source in &resolved.sources {
if let ResolvedSource::Primary(p) = source {
files.extend(enumerate_facet_files(
p,
&resolved.deny_paths,
workspace_root,
));
}
}
files.sort();
files.dedup();
files
}
fn shuffle(files: &mut [String], seed: u64) {
let mut state = seed
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
for i in (1..files.len()).rev() {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let j = ((state >> 33) as usize) % (i + 1);
files.swap(i, j);
}
}
fn load_state(cache_root: &Path, binding_name: &str) -> Option<RefinementState> {
let bytes = std::fs::read(state_path(cache_root, binding_name)).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn save_state(cache_root: &Path, binding_name: &str, state: &RefinementState) {
let path = state_path(cache_root, binding_name);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut bytes) = serde_json::to_vec_pretty(state) {
bytes.push(b'\n');
let _ = std::fs::write(path, bytes);
}
}
pub fn bump_verify_runs(cache_root: &Path, binding_name: &str) -> u64 {
let mut state = load_state(cache_root, binding_name).unwrap_or_default();
state.verify_runs = state.verify_runs.saturating_add(1);
let n = state.verify_runs;
save_state(cache_root, binding_name, &state);
n
}
pub fn next_rotation_batch(
cache_root: &Path,
binding_name: &str,
rotation_key: &str,
items: Vec<String>,
batch_size: usize,
) -> Option<Batch> {
let batch_size = batch_size.max(1);
if items.is_empty() {
return None;
}
let mut state = load_state(cache_root, binding_name).unwrap_or_default();
let mut cursor = state.rotations.remove(rotation_key).unwrap_or_default();
if cursor.order.is_empty() || cursor.cursor >= cursor.order.len() {
let rotation = cursor.rotation + u64::from(!cursor.order.is_empty());
let mut order = items;
shuffle(&mut order, rotation);
cursor = RotationCursor {
rotation,
cursor: 0,
order,
};
}
let end = (cursor.cursor + batch_size).min(cursor.order.len());
let files = cursor.order[cursor.cursor..end].to_vec();
let batch_index = cursor.cursor / batch_size + 1;
let total_batches = cursor.order.len().div_ceil(batch_size);
cursor.cursor += files.len();
let rotation = cursor.rotation;
state.rotations.insert(rotation_key.to_string(), cursor);
save_state(cache_root, binding_name, &state);
Some(Batch {
files,
rotation,
batch_index,
total_batches,
})
}
pub fn next_batch(
resolved: &ResolvedIngest,
workspace_root: &Path,
cache_root: &Path,
batch_size: usize,
) -> Option<Batch> {
let all_files = enumerate_source_files(resolved, workspace_root);
next_rotation_batch(
cache_root,
&resolved.name,
ROTATION_UNCOVERED_FILES,
all_files,
batch_size,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::binding::BuildMode;
use crate::ingest::resolve::Source;
use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
fn resolved(name: &str, batch_size: u32) -> ResolvedIngest {
ResolvedIngest {
name: name.to_string(),
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size,
deny_paths: vec![],
projection_ref: format!("{name}/p"),
projection_mem: name.to_string(),
projection_name: "p".to_string(),
intent: None,
sources: vec![ResolvedSource::Primary(Source {
name: "f".to_string(),
medium_type: MediumType::Codebase,
pointer: String::new(),
change_detection: None,
scope: vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}],
engagement: None,
preparation: None,
})],
destination_mem: name.to_string(),
rules: None,
post_actions: None,
}
}
#[test]
fn next_batch_walks_a_rotation_then_starts_a_new_one() {
let ws = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
let root = ws.path();
for i in 0..5 {
std::fs::write(root.join(format!("f{i}.rs")), "").unwrap();
}
let r = resolved("ref", 2);
let b1 = next_batch(&r, root, cache.path(), 2).unwrap();
assert_eq!(b1.rotation, 0);
assert_eq!(b1.batch_index, 1);
assert_eq!(b1.total_batches, 3); assert_eq!(b1.files.len(), 2);
let b2 = next_batch(&r, root, cache.path(), 2).unwrap();
assert_eq!(b2.batch_index, 2);
let b3 = next_batch(&r, root, cache.path(), 2).unwrap();
assert_eq!(b3.batch_index, 3);
assert_eq!(b3.files.len(), 1);
let b4 = next_batch(&r, root, cache.path(), 2).unwrap();
assert_eq!(b4.rotation, 1);
assert_eq!(b4.batch_index, 1);
let mut seen: Vec<String> = [b1.files, b2.files, b3.files].concat();
seen.sort();
seen.dedup();
assert_eq!(seen.len(), 5, "the rotation covers all files");
}
#[test]
fn named_rotation_is_deterministic_and_covers_the_whole_set() {
let cache = tempfile::tempdir().unwrap();
let items: Vec<String> = (0..6).map(|i| format!("id{i}")).collect();
let key = ROTATION_ANCHOR_ADJUDICATION;
let mut covered: Vec<String> = Vec::new();
let mut order_r0: Vec<String> = Vec::new();
for i in 0..3 {
let b = next_rotation_batch(cache.path(), "m/b", key, items.clone(), 2).unwrap();
assert_eq!(b.rotation, 0);
assert_eq!(b.batch_index, i + 1);
assert_eq!(b.total_batches, 3);
covered.extend(b.files.clone());
order_r0.extend(b.files);
}
let mut uniq = covered.clone();
uniq.sort();
uniq.dedup();
assert_eq!(uniq.len(), 6, "one rotation covers the whole set");
let b = next_rotation_batch(cache.path(), "m/b", key, items.clone(), 2).unwrap();
assert_eq!(
b.rotation, 1,
"a new rotation starts once the prior is done"
);
let cache2 = tempfile::tempdir().unwrap();
let mut order_repro: Vec<String> = Vec::new();
for _ in 0..3 {
let b = next_rotation_batch(cache2.path(), "m/b", key, items.clone(), 2).unwrap();
order_repro.extend(b.files);
}
assert_eq!(order_r0, order_repro, "same seed/state → same sequence");
}
#[test]
fn named_rotations_are_independent() {
let cache = tempfile::tempdir().unwrap();
let a: Vec<String> = (0..4).map(|i| format!("a{i}")).collect();
let files =
next_rotation_batch(cache.path(), "m/b", ROTATION_UNCOVERED_FILES, a.clone(), 2)
.unwrap();
let anchors = next_rotation_batch(
cache.path(),
"m/b",
ROTATION_ANCHOR_ADJUDICATION,
a.clone(),
2,
)
.unwrap();
assert_eq!(files.batch_index, 1);
assert_eq!(anchors.batch_index, 1);
let files2 =
next_rotation_batch(cache.path(), "m/b", ROTATION_UNCOVERED_FILES, a.clone(), 2)
.unwrap();
assert_eq!(files2.batch_index, 2);
let anchors_again =
next_rotation_batch(cache.path(), "m/b", ROTATION_ANCHOR_ADJUDICATION, a, 2).unwrap();
assert_eq!(anchors_again.batch_index, 2, "anchor cursor is independent");
}
#[test]
fn verify_run_counter_ticks_and_persists() {
let cache = tempfile::tempdir().unwrap();
assert_eq!(bump_verify_runs(cache.path(), "m/b"), 1);
assert_eq!(bump_verify_runs(cache.path(), "m/b"), 2);
assert_eq!(bump_verify_runs(cache.path(), "m/b"), 3);
assert_eq!(bump_verify_runs(cache.path(), "m/other"), 1);
}
}