use super::{
ContentHash, ContextManager, FileArtifact, FileOperation, LineRange, MessageClassification,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct PartialReadTracker {
pub path: PathBuf,
pub ranges: Vec<TrackedRange>,
pub has_full_read: bool,
}
#[derive(Debug, Clone)]
pub struct TrackedRange {
pub range: LineRange,
pub message_id: String,
pub sequence: u64,
pub content_hash: Option<ContentHash>,
}
impl PartialReadTracker {
pub fn new(path: PathBuf) -> Self {
Self {
path,
ranges: Vec::new(),
has_full_read: false,
}
}
pub fn add_range(&mut self, range: TrackedRange) {
if range.range.is_full_read() {
self.has_full_read = true;
}
self.ranges.push(range);
self.ranges.sort_by_key(|r| r.range.offset);
}
pub fn has_contiguous_coverage_from_start(&self) -> bool {
if self.has_full_read {
return true;
}
if self.ranges.is_empty() {
return false;
}
let first = &self.ranges[0];
if first.range.offset != 0 {
return false;
}
let mut end = first.range.limit.unwrap_or(usize::MAX);
for range in self.ranges.iter().skip(1) {
if range.range.offset > end {
return false;
}
if let Some(limit) = range.range.limit {
end = end.max(range.range.offset + limit);
} else {
return true;
}
}
true
}
pub fn superseded_ranges(&self) -> Vec<String> {
let mut superseded = Vec::new();
for (i, range_a) in self.ranges.iter().enumerate() {
for range_b in self.ranges.iter().skip(i + 1) {
if range_b.range.contains(&range_a.range) && range_b.sequence > range_a.sequence {
superseded.push(range_a.message_id.clone());
break;
}
}
}
superseded
}
pub fn coverage_gaps(&self) -> Vec<LineRange> {
if self.has_full_read || self.ranges.is_empty() {
return Vec::new();
}
let mut gaps = Vec::new();
let mut current_end = 0;
for range in &self.ranges {
if range.range.offset > current_end {
gaps.push(LineRange::new(
current_end,
Some(range.range.offset - current_end),
));
}
if let Some(limit) = range.range.limit {
current_end = current_end.max(range.range.offset + limit);
} else {
return gaps;
}
}
gaps
}
}
#[derive(Debug, Clone)]
pub struct MultiFilePatch {
pub patch_id: String,
pub files: Vec<PathBuf>,
pub message_ids: Vec<String>,
pub is_complete: bool,
}
impl MultiFilePatch {
pub fn new(patch_id: impl Into<String>) -> Self {
Self {
patch_id: patch_id.into(),
files: Vec::new(),
message_ids: Vec::new(),
is_complete: false,
}
}
pub fn add_file(&mut self, path: PathBuf, message_id: String) {
if !self.files.contains(&path) {
self.files.push(path);
}
if !self.message_ids.contains(&message_id) {
self.message_ids.push(message_id);
}
}
pub fn mark_complete(&mut self) {
self.is_complete = true;
}
}
#[derive(Debug, Default)]
pub struct MultiFilePatchTracker {
patches: HashMap<String, MultiFilePatch>,
message_to_patch: HashMap<String, String>,
}
impl MultiFilePatchTracker {
pub fn new() -> Self {
Self::default()
}
pub fn start_patch(&mut self, patch_id: impl Into<String>) {
let patch_id = patch_id.into();
self.patches
.insert(patch_id.clone(), MultiFilePatch::new(patch_id));
}
pub fn add_to_patch(&mut self, patch_id: &str, path: PathBuf, message_id: String) {
if let Some(patch) = self.patches.get_mut(patch_id) {
patch.add_file(path, message_id.clone());
self.message_to_patch
.insert(message_id, patch_id.to_string());
}
}
pub fn complete_patch(&mut self, patch_id: &str) {
if let Some(patch) = self.patches.get_mut(patch_id) {
patch.mark_complete();
}
}
pub fn get_patch_for_message(&self, message_id: &str) -> Option<&MultiFilePatch> {
self.message_to_patch
.get(message_id)
.and_then(|patch_id| self.patches.get(patch_id))
}
pub fn is_incomplete_atomic(&self, message_id: &str) -> bool {
self.get_patch_for_message(message_id)
.map(|p| !p.is_complete)
.unwrap_or(false)
}
}
#[derive(Debug, Clone)]
pub struct FileRename {
pub old_path: PathBuf,
pub new_path: PathBuf,
pub message_id: String,
pub sequence: u64,
}
#[derive(Debug, Clone)]
pub struct FileDeletion {
pub path: PathBuf,
pub message_id: String,
pub sequence: u64,
}
#[derive(Debug, Default)]
pub struct FileLifecycleTracker {
pub renames: Vec<FileRename>,
pub deletions: Vec<FileDeletion>,
path_aliases: HashMap<PathBuf, PathBuf>,
}
impl FileLifecycleTracker {
pub fn new() -> Self {
Self::default()
}
pub fn record_rename(&mut self, rename: FileRename) {
self.path_aliases
.insert(rename.old_path.clone(), rename.new_path.clone());
self.renames.push(rename);
}
pub fn record_deletion(&mut self, deletion: FileDeletion) {
self.deletions.push(deletion);
}
pub fn resolve_path(&self, path: &Path) -> PathBuf {
let mut current = path.to_path_buf();
let mut visited = std::collections::HashSet::new();
while let Some(new_path) = self.path_aliases.get(¤t) {
if visited.contains(new_path) {
break;
}
visited.insert(current.clone());
current = new_path.clone();
}
current
}
pub fn is_deleted(&self, path: &Path) -> bool {
let resolved = self.resolve_path(path);
self.deletions.iter().any(|d| d.path == resolved)
}
pub fn deletion_sequence(&self, path: &Path) -> Option<u64> {
let resolved = self.resolve_path(path);
self.deletions
.iter()
.find(|d| d.path == resolved)
.map(|d| d.sequence)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinaryFilePolicy {
KeepReference,
HashOnly,
TreatAsText,
Exclude,
}
#[derive(Debug, Clone)]
pub struct LargeFilePolicy {
pub max_size_bytes: usize,
pub max_lines: usize,
pub truncate: bool,
pub include_summary: bool,
}
impl Default for LargeFilePolicy {
fn default() -> Self {
Self {
max_size_bytes: 100_000, max_lines: 2000,
truncate: true,
include_summary: true,
}
}
}
pub fn is_likely_binary(content: &str) -> bool {
let non_printable = content
.chars()
.take(1000) .filter(|c| !c.is_ascii_graphic() && !c.is_ascii_whitespace())
.count();
let sample_size = content.chars().take(1000).count();
if sample_size == 0 {
return false;
}
(non_printable as f64 / sample_size as f64) > 0.1
}
#[derive(Debug, Clone)]
pub struct FileMetadata {
pub is_binary: bool,
pub size_bytes: Option<usize>,
pub line_count: Option<usize>,
pub recommended_action: FileAction,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileAction {
Include,
Truncate {
max_lines: usize,
},
ReferenceOnly,
Exclude,
}
pub fn evaluate_file(
content: &str,
binary_policy: BinaryFilePolicy,
large_policy: &LargeFilePolicy,
) -> FileMetadata {
let is_binary = is_likely_binary(content);
let size_bytes = content.len();
let line_count = content.lines().count();
let recommended_action = if is_binary {
match binary_policy {
BinaryFilePolicy::KeepReference | BinaryFilePolicy::HashOnly => {
FileAction::ReferenceOnly
}
BinaryFilePolicy::TreatAsText => FileAction::Include,
BinaryFilePolicy::Exclude => FileAction::Exclude,
}
} else if size_bytes > large_policy.max_size_bytes || line_count > large_policy.max_lines {
if large_policy.truncate {
FileAction::Truncate {
max_lines: large_policy.max_lines,
}
} else {
FileAction::Exclude
}
} else {
FileAction::Include
};
FileMetadata {
is_binary,
size_bytes: Some(size_bytes),
line_count: Some(line_count),
recommended_action,
}
}
#[derive(Debug, Clone)]
pub struct SyntheticSnapshot {
pub path: PathBuf,
pub content: String,
pub content_hash: ContentHash,
pub source_patches: Vec<String>,
pub is_complete: bool,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SnapshotConfig {
pub enable_reconstruction: bool,
pub max_patches: usize,
pub strict_mode: bool,
}
impl Default for SnapshotConfig {
fn default() -> Self {
Self {
enable_reconstruction: true,
max_patches: 50,
strict_mode: false,
}
}
}
pub fn build_synthetic_snapshot(
artifact: &FileArtifact,
_config: &SnapshotConfig,
) -> Result<SyntheticSnapshot, String> {
let source_patches: Vec<String> = artifact
.operations
.iter()
.filter(|op| op.operation == FileOperation::Edit && op.is_result)
.map(|op| op.key.message_id.clone())
.collect();
Ok(SyntheticSnapshot {
path: artifact.path.clone(),
content: String::new(), content_hash: ContentHash::from_content(""),
source_patches,
is_complete: false,
warnings: vec!["Synthetic reconstruction not fully implemented".to_string()],
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FallbackStrategy {
KeepAll,
KeepLatestOnly,
RequestFreshReads,
TruncateRecent {
max_operations: usize,
},
}
pub fn determine_fallback(
error: &str,
file_count: usize,
operation_count: usize,
) -> FallbackStrategy {
if operation_count > 100 {
return FallbackStrategy::TruncateRecent { max_operations: 50 };
}
if file_count > 10 && error.contains("reconstruction") {
return FallbackStrategy::KeepLatestOnly;
}
FallbackStrategy::KeepAll
}
pub fn apply_fallback(manager: &ContextManager, strategy: FallbackStrategy) -> Vec<String> {
match strategy {
FallbackStrategy::KeepAll => {
let mut ids: Vec<_> = manager.classifier().classifications().values().collect();
ids.sort_by_key(|c| c.sequence);
ids.into_iter().map(|c| c.message_id.clone()).collect()
}
FallbackStrategy::KeepLatestOnly => {
let mut latest_per_file: HashMap<PathBuf, &MessageClassification> = HashMap::new();
for classification in manager.classifier().classifications().values() {
if let Some(path) = &classification.file_path {
let entry = latest_per_file
.entry(path.clone())
.or_insert(classification);
if classification.sequence > entry.sequence {
*entry = classification;
}
}
}
let mut ids: Vec<_> = latest_per_file.values().collect();
ids.sort_by_key(|c| c.sequence);
ids.into_iter().map(|c| c.message_id.clone()).collect()
}
FallbackStrategy::RequestFreshReads => {
Vec::new()
}
FallbackStrategy::TruncateRecent { max_operations } => {
let mut ids: Vec<_> = manager.classifier().classifications().values().collect();
ids.sort_by_key(|c| c.sequence);
ids.into_iter()
.rev()
.take(max_operations)
.map(|c| c.message_id.clone())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_partial_read_tracker_contiguous() {
let mut tracker = PartialReadTracker::new(PathBuf::from("/test/file.rs"));
tracker.add_range(TrackedRange {
range: LineRange::new(0, Some(50)),
message_id: "msg-1".to_string(),
sequence: 1,
content_hash: None,
});
tracker.add_range(TrackedRange {
range: LineRange::new(50, Some(50)),
message_id: "msg-2".to_string(),
sequence: 2,
content_hash: None,
});
assert!(tracker.has_contiguous_coverage_from_start());
}
#[test]
fn test_partial_read_tracker_gap() {
let mut tracker = PartialReadTracker::new(PathBuf::from("/test/file.rs"));
tracker.add_range(TrackedRange {
range: LineRange::new(0, Some(50)),
message_id: "msg-1".to_string(),
sequence: 1,
content_hash: None,
});
tracker.add_range(TrackedRange {
range: LineRange::new(100, Some(50)),
message_id: "msg-2".to_string(),
sequence: 2,
content_hash: None,
});
assert!(!tracker.has_contiguous_coverage_from_start());
let gaps = tracker.coverage_gaps();
assert_eq!(gaps.len(), 1);
assert_eq!(gaps[0].offset, 50);
assert_eq!(gaps[0].limit, Some(50));
}
#[test]
fn test_file_lifecycle_rename() {
let mut tracker = FileLifecycleTracker::new();
tracker.record_rename(FileRename {
old_path: PathBuf::from("/old/path.rs"),
new_path: PathBuf::from("/new/path.rs"),
message_id: "msg-1".to_string(),
sequence: 1,
});
let resolved = tracker.resolve_path(&PathBuf::from("/old/path.rs"));
assert_eq!(resolved, PathBuf::from("/new/path.rs"));
}
#[test]
fn test_file_lifecycle_chained_rename() {
let mut tracker = FileLifecycleTracker::new();
tracker.record_rename(FileRename {
old_path: PathBuf::from("/a.rs"),
new_path: PathBuf::from("/b.rs"),
message_id: "msg-1".to_string(),
sequence: 1,
});
tracker.record_rename(FileRename {
old_path: PathBuf::from("/b.rs"),
new_path: PathBuf::from("/c.rs"),
message_id: "msg-2".to_string(),
sequence: 2,
});
let resolved = tracker.resolve_path(&PathBuf::from("/a.rs"));
assert_eq!(resolved, PathBuf::from("/c.rs"));
}
#[test]
fn test_is_likely_binary() {
assert!(!is_likely_binary("Hello, world!"));
assert!(!is_likely_binary("fn main() { println!(\"test\"); }"));
let binary = "Hello\0World\0Binary\0Content";
assert!(is_likely_binary(binary));
}
#[test]
fn test_evaluate_file_normal() {
let content = "fn main() {\n println!(\"Hello\");\n}";
let metadata = evaluate_file(
content,
BinaryFilePolicy::Exclude,
&LargeFilePolicy::default(),
);
assert!(!metadata.is_binary);
assert_eq!(metadata.recommended_action, FileAction::Include);
}
#[test]
fn test_evaluate_file_large() {
let content = "line\n".repeat(5000); let metadata = evaluate_file(
&content,
BinaryFilePolicy::Exclude,
&LargeFilePolicy {
max_lines: 2000,
truncate: true,
..Default::default()
},
);
assert!(!metadata.is_binary);
assert_eq!(
metadata.recommended_action,
FileAction::Truncate { max_lines: 2000 }
);
}
#[test]
fn test_fallback_strategy() {
let strategy = determine_fallback("generic error", 5, 50);
assert_eq!(strategy, FallbackStrategy::KeepAll);
let strategy = determine_fallback("generic error", 5, 150);
assert_eq!(
strategy,
FallbackStrategy::TruncateRecent { max_operations: 50 }
);
}
}