1use hashbrown::HashMap;
9use std::fmt::Write as _;
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use crate::config::constants::diff::MAX_PREVIEW_BYTES;
14use serde::{Deserialize, Serialize};
15use tokio::sync::RwLock;
16use vtcode_diff::{DiffOptions, format_unified_diff};
17
18#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub struct ChangeAttribution {
22 pub model_id: Option<String>,
24 pub provider: Option<String>,
26 pub session_id: Option<String>,
28 pub turn_number: Option<u32>,
30 pub contributor_type: String,
32}
33
34impl ChangeAttribution {
35 pub fn ai(model_id: impl Into<String>, provider: impl Into<String>) -> Self {
37 Self {
38 model_id: Some(model_id.into()),
39 provider: Some(provider.into()),
40 session_id: None,
41 turn_number: None,
42 contributor_type: "ai".to_string(),
43 }
44 }
45
46 pub fn human() -> Self {
48 Self {
49 contributor_type: "human".to_string(),
50 ..Default::default()
51 }
52 }
53
54 pub fn unknown() -> Self {
56 Self {
57 contributor_type: "unknown".to_string(),
58 ..Default::default()
59 }
60 }
61
62 pub fn with_session(mut self, session_id: impl Into<String>, turn: u32) -> Self {
64 self.session_id = Some(session_id.into());
65 self.turn_number = Some(turn);
66 self
67 }
68
69 pub fn normalized_model_id(&self) -> Option<String> {
71 match (&self.model_id, &self.provider) {
72 (Some(model), Some(provider)) if !model.contains('/') => Some(format!("{provider}/{model}")),
73 (Some(model), _) => Some(model.clone()),
74 _ => None,
75 }
76 }
77}
78
79#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81pub struct FileChange {
82 #[serde(flatten)]
84 pub kind: FileChangeKind,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub attribution: Option<ChangeAttribution>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub line_range: Option<(u32, u32)>,
91}
92
93#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(tag = "type", rename_all = "snake_case")]
96pub enum FileChangeKind {
97 Add { content: String },
99 Delete { original_content: String },
101 Update { old_content: String, new_content: String },
103 Rename {
105 new_path: PathBuf,
106 old_content: Option<String>,
107 new_content: Option<String>,
108 },
109}
110
111impl FileChange {
112 pub fn add(content: impl Into<String>) -> Self {
114 Self {
115 kind: FileChangeKind::Add { content: content.into() },
116 attribution: None,
117 line_range: None,
118 }
119 }
120
121 pub fn delete(original_content: impl Into<String>) -> Self {
123 Self {
124 kind: FileChangeKind::Delete { original_content: original_content.into() },
125 attribution: None,
126 line_range: None,
127 }
128 }
129
130 pub fn update(old_content: impl Into<String>, new_content: impl Into<String>) -> Self {
132 Self {
133 kind: FileChangeKind::Update {
134 old_content: old_content.into(),
135 new_content: new_content.into(),
136 },
137 attribution: None,
138 line_range: None,
139 }
140 }
141
142 pub fn rename(new_path: PathBuf, old_content: Option<String>, new_content: Option<String>) -> Self {
144 Self {
145 kind: FileChangeKind::Rename { new_path, old_content, new_content },
146 attribution: None,
147 line_range: None,
148 }
149 }
150
151 pub fn with_attribution(mut self, attribution: ChangeAttribution) -> Self {
153 self.attribution = Some(attribution);
154 self
155 }
156
157 pub fn with_line_range(mut self, start: u32, end: u32) -> Self {
159 self.line_range = Some((start, end));
160 self
161 }
162
163 pub fn new_content(&self) -> Option<&str> {
165 match &self.kind {
166 FileChangeKind::Add { content } => Some(content),
167 FileChangeKind::Update { new_content, .. } => Some(new_content),
168 FileChangeKind::Rename { new_content, .. } => new_content.as_deref(),
169 FileChangeKind::Delete { .. } => None,
170 }
171 }
172
173 pub fn old_content(&self) -> Option<&str> {
175 match &self.kind {
176 FileChangeKind::Delete { original_content } => Some(original_content),
177 FileChangeKind::Update { old_content, .. } => Some(old_content),
178 FileChangeKind::Rename { old_content, .. } => old_content.as_deref(),
179 FileChangeKind::Add { .. } => None,
180 }
181 }
182
183 pub fn is_add(&self) -> bool {
185 matches!(self.kind, FileChangeKind::Add { .. })
186 }
187
188 pub fn is_delete(&self) -> bool {
190 matches!(self.kind, FileChangeKind::Delete { .. })
191 }
192
193 pub fn is_update(&self) -> bool {
195 matches!(self.kind, FileChangeKind::Update { .. })
196 }
197
198 pub fn is_rename(&self) -> bool {
200 matches!(self.kind, FileChangeKind::Rename { .. })
201 }
202
203 pub fn new_line_count(&self) -> usize {
205 self.new_content().map(|c| c.lines().count()).unwrap_or(0)
206 }
207
208 pub fn from_legacy(legacy: &super::tool_handler::FileChange, attribution: Option<ChangeAttribution>) -> Self {
212 let kind = match legacy {
213 super::tool_handler::FileChange::Add { content } => FileChangeKind::Add { content: content.clone() },
214 super::tool_handler::FileChange::Delete => FileChangeKind::Delete {
215 original_content: String::new(), },
217 super::tool_handler::FileChange::Update { old_content, new_content } => FileChangeKind::Update {
218 old_content: old_content.clone(),
219 new_content: new_content.clone(),
220 },
221 super::tool_handler::FileChange::Rename { new_path, content } => FileChangeKind::Rename {
222 new_path: new_path.clone(),
223 old_content: None,
224 new_content: content.clone(),
225 },
226 };
227 Self { kind, attribution, line_range: None }
228 }
229
230 pub fn to_legacy(&self) -> super::tool_handler::FileChange {
232 match &self.kind {
233 FileChangeKind::Add { content } => super::tool_handler::FileChange::Add { content: content.clone() },
234 FileChangeKind::Delete { .. } => super::tool_handler::FileChange::Delete,
235 FileChangeKind::Update { old_content, new_content } => super::tool_handler::FileChange::Update {
236 old_content: old_content.clone(),
237 new_content: new_content.clone(),
238 },
239 FileChangeKind::Rename { new_path, new_content, .. } => super::tool_handler::FileChange::Rename {
240 new_path: new_path.clone(),
241 content: new_content.clone(),
242 },
243 }
244 }
245}
246
247#[derive(Default)]
249pub struct TurnDiffTracker {
250 changes: HashMap<PathBuf, FileChange>,
251 pending_changes: Option<HashMap<PathBuf, FileChange>>,
252 current_attribution: Option<ChangeAttribution>,
254}
255
256impl FileChange {
257 pub fn exceeds_content_limit(&self, limit: usize) -> bool {
259 match &self.kind {
260 FileChangeKind::Add { content } => content.len() > limit,
261 FileChangeKind::Delete { original_content } => original_content.len() > limit,
262 FileChangeKind::Update { old_content, new_content } => {
263 old_content.len() > limit || new_content.len() > limit
264 }
265 FileChangeKind::Rename { old_content, new_content, .. } => {
266 old_content.as_deref().is_some_and(|content| content.len() > limit)
267 || new_content.as_deref().is_some_and(|content| content.len() > limit)
268 }
269 }
270 }
271}
272
273impl TurnDiffTracker {
274 pub fn new() -> Self {
275 Self::default()
276 }
277
278 pub fn set_attribution(&mut self, attribution: ChangeAttribution) {
280 self.current_attribution = Some(attribution);
281 }
282
283 pub fn clear_attribution(&mut self) {
285 self.current_attribution = None;
286 }
287
288 pub fn current_attribution(&self) -> Option<&ChangeAttribution> {
290 self.current_attribution.as_ref()
291 }
292
293 pub fn on_patch_begin(&mut self, changes: HashMap<PathBuf, FileChange>) {
297 let changes_with_attribution: HashMap<PathBuf, FileChange> = changes
299 .into_iter()
300 .map(|(path, mut change)| {
301 if change.attribution.is_none() {
302 change.attribution = self.current_attribution.clone();
303 }
304 (path, change)
305 })
306 .collect();
307 self.pending_changes = Some(changes_with_attribution);
308 }
309
310 pub fn on_patch_end(&mut self, success: bool) {
314 if success {
315 if let Some(pending) = self.pending_changes.take() {
316 for (path, change) in pending {
317 self.merge_change(path, change);
318 }
319 }
320 } else {
321 self.pending_changes = None;
322 }
323 }
324
325 fn merge_change(&mut self, path: PathBuf, change: FileChange) {
327 if let Some(existing) = self.changes.get(&path) {
328 let merged = match (&existing.kind, &change.kind) {
330 (FileChangeKind::Add { .. }, FileChangeKind::Update { new_content, .. }) => FileChange {
332 kind: FileChangeKind::Add { content: new_content.clone() },
333 attribution: change.attribution.clone().or(existing.attribution.clone()),
334 line_range: change.line_range,
335 },
336 (FileChangeKind::Add { .. }, FileChangeKind::Delete { .. }) => {
338 self.changes.remove(&path);
339 return;
340 }
341 (FileChangeKind::Update { old_content, .. }, FileChangeKind::Update { new_content, .. }) => {
343 FileChange {
344 kind: FileChangeKind::Update {
345 old_content: old_content.clone(),
346 new_content: new_content.clone(),
347 },
348 attribution: change.attribution.clone().or(existing.attribution.clone()),
349 line_range: change.line_range,
350 }
351 }
352 (FileChangeKind::Update { old_content, .. }, FileChangeKind::Delete { .. }) => FileChange {
354 kind: FileChangeKind::Delete { original_content: old_content.clone() },
355 attribution: change.attribution.clone().or(existing.attribution.clone()),
356 line_range: None,
357 },
358 (FileChangeKind::Delete { original_content }, FileChangeKind::Add { content }) => FileChange {
360 kind: FileChangeKind::Update {
361 old_content: original_content.clone(),
362 new_content: content.clone(),
363 },
364 attribution: change.attribution.clone().or(existing.attribution.clone()),
365 line_range: change.line_range,
366 },
367 _ => change,
369 };
370 self.changes.insert(path, merged);
371 } else {
372 self.changes.insert(path, change);
373 }
374 }
375
376 pub fn changes(&self) -> &HashMap<PathBuf, FileChange> {
378 &self.changes
379 }
380
381 pub fn pending_changes(&self) -> Option<&HashMap<PathBuf, FileChange>> {
383 self.pending_changes.as_ref()
384 }
385
386 pub fn has_changes(&self) -> bool {
388 !self.changes.is_empty()
389 }
390
391 pub fn get_unified_diff(&self) -> String {
397 let mut diff = String::new();
398
399 let mut entries: Vec<(&PathBuf, &FileChange)> = self.changes.iter().collect();
400 entries.sort_by(|(left, _), (right, _)| (*left).cmp(*right));
401
402 for (path, change) in entries {
403 let path_str = path.display();
404 if change.exceeds_content_limit(MAX_PREVIEW_BYTES) {
405 let _ = write!(
406 diff,
407 "diff --git a/{path_str} b/{path_str}\n@@ summary: content exceeds {MAX_PREVIEW_BYTES} bytes; full diff suppressed\n"
408 );
409 diff.push('\n');
410 continue;
411 }
412 match &change.kind {
413 FileChangeKind::Add { content } => {
414 let new_label = path_str.to_string();
415 diff.push_str(&compute_unified_diff_with_labels("", content, "/dev/null", &new_label));
416 }
417 FileChangeKind::Delete { original_content } => {
418 let old_label = path_str.to_string();
419 diff.push_str(&compute_unified_diff_with_labels(original_content, "", &old_label, "/dev/null"));
420 }
421 FileChangeKind::Update { old_content, new_content } => {
422 let filename = path_str.to_string();
423 diff.push_str(&compute_unified_diff_with_labels(old_content, new_content, &filename, &filename));
424 }
425 FileChangeKind::Rename { new_path, old_content, new_content } => {
426 if let (Some(old), Some(new)) = (old_content, new_content) {
427 let old_label = path_str.to_string();
428 let new_label = new_path.to_string_lossy();
429 diff.push_str(&compute_unified_diff_with_labels(old, new, &old_label, &new_label));
430 }
431 }
432 }
433 diff.push('\n');
434 }
435
436 diff
437 }
438
439 pub fn clear(&mut self) {
441 self.changes.clear();
442 self.pending_changes = None;
443 }
444}
445
446pub type SharedTurnDiffTracker = Arc<RwLock<TurnDiffTracker>>;
448
449pub fn new_shared_tracker() -> SharedTurnDiffTracker {
451 Arc::new(RwLock::new(TurnDiffTracker::new()))
452}
453
454fn compute_unified_diff_with_labels(old: &str, new: &str, old_label: &str, new_label: &str) -> String {
456 let old_label = format!("a/{old_label}");
457 let new_label = format!("b/{new_label}");
458 format_unified_diff(
459 old,
460 new,
461 DiffOptions {
462 context_lines: 3,
463 old_label: Some(&old_label),
464 new_label: Some(&new_label),
465 missing_newline_hint: false,
466 ..DiffOptions::default()
467 },
468 )
469}
470
471#[cfg(test)]
473fn format_addition_diff(content: &str) -> String {
474 compute_unified_diff_with_labels("", content, "file", "file")
475}
476
477#[cfg(test)]
479fn format_deletion_diff(content: &str) -> String {
480 compute_unified_diff_with_labels(content, "", "file", "file")
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn test_on_patch_begin_and_end_success() {
489 let mut tracker = TurnDiffTracker::new();
490
491 let mut changes = HashMap::new();
492 changes.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
493
494 tracker.on_patch_begin(changes);
495 assert!(tracker.pending_changes().is_some());
496 assert!(!tracker.has_changes());
497
498 tracker.on_patch_end(true);
499 assert!(tracker.pending_changes().is_none());
500 assert!(tracker.has_changes());
501 }
502
503 #[test]
504 fn test_on_patch_end_failure() {
505 let mut tracker = TurnDiffTracker::new();
506
507 let mut changes = HashMap::new();
508 changes.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
509
510 tracker.on_patch_begin(changes);
511 tracker.on_patch_end(false);
512
513 assert!(tracker.pending_changes().is_none());
514 assert!(!tracker.has_changes());
515 }
516
517 #[test]
518 fn test_merge_add_then_update() {
519 let mut tracker = TurnDiffTracker::new();
520
521 let mut changes1 = HashMap::new();
523 changes1.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
524 tracker.on_patch_begin(changes1);
525 tracker.on_patch_end(true);
526
527 let mut changes2 = HashMap::new();
529 changes2.insert(PathBuf::from("test.txt"), FileChange::update("hello", "world"));
530 tracker.on_patch_begin(changes2);
531 tracker.on_patch_end(true);
532
533 let change = tracker.changes().get(&PathBuf::from("test.txt")).unwrap();
535 assert!(change.is_add());
536 assert_eq!(change.new_content(), Some("world"));
537 }
538
539 #[test]
540 fn test_merge_add_then_delete() {
541 let mut tracker = TurnDiffTracker::new();
542
543 let mut changes1 = HashMap::new();
545 changes1.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
546 tracker.on_patch_begin(changes1);
547 tracker.on_patch_end(true);
548
549 let mut changes2 = HashMap::new();
551 changes2.insert(PathBuf::from("test.txt"), FileChange::delete("hello"));
552 tracker.on_patch_begin(changes2);
553 tracker.on_patch_end(true);
554
555 assert!(!tracker.has_changes());
557 }
558
559 #[test]
560 fn test_get_unified_diff() {
561 let mut tracker = TurnDiffTracker::new();
562
563 let mut changes = HashMap::new();
564 changes.insert(PathBuf::from("new.txt"), FileChange::add("line1\nline2"));
565 tracker.on_patch_begin(changes);
566 tracker.on_patch_end(true);
567
568 let diff = tracker.get_unified_diff();
569 assert!(diff.contains("--- "));
570 assert!(diff.contains("+++ "));
571 assert!(diff.contains("new.txt"));
572 assert!(diff.contains("+line1"));
573 assert!(diff.contains("+line2"));
574 }
575
576 #[test]
577 fn test_attribution_propagation() {
578 let mut tracker = TurnDiffTracker::new();
579 tracker.set_attribution(ChangeAttribution::ai("claude-opus-4", "anthropic"));
580
581 let mut changes = HashMap::new();
582 changes.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
583 tracker.on_patch_begin(changes);
584 tracker.on_patch_end(true);
585
586 let change = tracker.changes().get(&PathBuf::from("test.txt")).unwrap();
587 assert!(change.attribution.is_some());
588 let attr = change.attribution.as_ref().unwrap();
589 assert_eq!(attr.contributor_type, "ai");
590 assert_eq!(attr.model_id, Some("claude-opus-4".to_string()));
591 }
592
593 #[test]
594 fn test_format_addition_diff() {
595 let diff = format_addition_diff("line1\nline2");
596 assert!(diff.contains("@@"));
597 assert!(diff.contains("+line1"));
598 assert!(diff.contains("+line2"));
599 }
600
601 #[test]
602 fn test_format_deletion_diff() {
603 let diff = format_deletion_diff("line1\nline2");
604 assert!(diff.contains("@@"));
605 assert!(diff.contains("-line1"));
606 assert!(diff.contains("-line2"));
607 }
608
609 #[test]
610 fn test_compute_unified_diff() {
611 let old = "line1\nline2\nline3";
612 let new = "line1\nmodified\nline3";
613 let diff = compute_unified_diff_with_labels(old, new, "file.txt", "file.txt");
614
615 assert!(diff.contains(" line1"));
616 assert!(diff.contains("-line2"));
617 assert!(diff.contains("+modified"));
618 assert!(diff.contains(" line3"));
619 assert!(diff.starts_with("--- a/"));
620 }
621
622 #[test]
623 fn test_file_change_accessors() {
624 let add = FileChange::add("hello");
625 assert_eq!(add.new_content(), Some("hello"));
626 assert_eq!(add.old_content(), None);
627 assert!(add.is_add());
628
629 let delete = FileChange::delete("goodbye");
630 assert_eq!(delete.new_content(), None);
631 assert_eq!(delete.old_content(), Some("goodbye"));
632 assert!(delete.is_delete());
633
634 let update = FileChange::update("old", "new");
635 assert_eq!(update.new_content(), Some("new"));
636 assert_eq!(update.old_content(), Some("old"));
637 assert!(update.is_update());
638 }
639
640 #[test]
641 fn test_normalized_model_id() {
642 let attr = ChangeAttribution::ai("claude-opus-4", "anthropic");
643 assert_eq!(attr.normalized_model_id(), Some("anthropic/claude-opus-4".to_string()));
644
645 let attr2 = ChangeAttribution::ai("anthropic/claude-opus-4", "anthropic");
646 assert_eq!(attr2.normalized_model_id(), Some("anthropic/claude-opus-4".to_string()));
647 }
648
649 #[tokio::test]
650 async fn test_shared_tracker() {
651 let tracker = new_shared_tracker();
652
653 {
654 let mut t = tracker.write().await;
655 let mut changes = HashMap::new();
656 changes.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
657 t.on_patch_begin(changes);
658 t.on_patch_end(true);
659 }
660
661 {
662 let t = tracker.read().await;
663 assert!(t.has_changes());
664 }
665 }
666
667 #[test]
668 fn test_file_change_serialization() {
669 let change = FileChange::add("fn main() {}")
670 .with_attribution(ChangeAttribution::ai("claude-opus-4", "anthropic"))
671 .with_line_range(1, 5);
672
673 let json = serde_json::to_string_pretty(&change).unwrap();
674 assert!(json.contains("\"type\": \"add\""));
675 assert!(json.contains("\"content\": \"fn main() {}\""));
676 assert!(json.contains("\"contributor_type\": \"ai\""));
677 assert!(json.contains("\"model_id\": \"claude-opus-4\""));
678
679 let restored: FileChange = serde_json::from_str(&json).unwrap();
681 assert!(restored.is_add());
682 assert_eq!(restored.new_content(), Some("fn main() {}"));
683 assert!(restored.attribution.is_some());
684 }
685
686 #[test]
687 fn test_change_attribution_serialization() {
688 let attr = ChangeAttribution::ai("gpt-5", "openai").with_session("session-abc", 3);
689
690 let json = serde_json::to_string(&attr).unwrap();
691 let restored: ChangeAttribution = serde_json::from_str(&json).unwrap();
692
693 assert_eq!(restored.model_id, Some("gpt-5".to_string()));
694 assert_eq!(restored.provider, Some("openai".to_string()));
695 assert_eq!(restored.session_id, Some("session-abc".to_string()));
696 assert_eq!(restored.turn_number, Some(3));
697 assert_eq!(restored.contributor_type, "ai");
698 }
699}