1use hashbrown::HashMap;
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use serde::{Deserialize, Serialize};
13use tokio::sync::RwLock;
14
15#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub struct ChangeAttribution {
19 pub model_id: Option<String>,
21 pub provider: Option<String>,
23 pub session_id: Option<String>,
25 pub turn_number: Option<u32>,
27 pub contributor_type: String,
29}
30
31impl ChangeAttribution {
32 pub fn ai(model_id: impl Into<String>, provider: impl Into<String>) -> Self {
34 Self {
35 model_id: Some(model_id.into()),
36 provider: Some(provider.into()),
37 session_id: None,
38 turn_number: None,
39 contributor_type: "ai".to_string(),
40 }
41 }
42
43 pub fn human() -> Self {
45 Self {
46 contributor_type: "human".to_string(),
47 ..Default::default()
48 }
49 }
50
51 pub fn unknown() -> Self {
53 Self {
54 contributor_type: "unknown".to_string(),
55 ..Default::default()
56 }
57 }
58
59 pub fn with_session(mut self, session_id: impl Into<String>, turn: u32) -> Self {
61 self.session_id = Some(session_id.into());
62 self.turn_number = Some(turn);
63 self
64 }
65
66 pub fn normalized_model_id(&self) -> Option<String> {
68 match (&self.model_id, &self.provider) {
69 (Some(model), Some(provider)) if !model.contains('/') => {
70 Some(format!("{}/{}", provider, model))
71 }
72 (Some(model), _) => Some(model.clone()),
73 _ => None,
74 }
75 }
76}
77
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
80pub struct FileChange {
81 #[serde(flatten)]
83 pub kind: FileChangeKind,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub attribution: Option<ChangeAttribution>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub line_range: Option<(u32, u32)>,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(tag = "type", rename_all = "snake_case")]
95pub enum FileChangeKind {
96 Add { content: String },
98 Delete { original_content: String },
100 Update {
102 old_content: String,
103 new_content: String,
104 },
105 Rename {
107 new_path: PathBuf,
108 old_content: Option<String>,
109 new_content: Option<String>,
110 },
111}
112
113impl FileChange {
114 pub fn add(content: impl Into<String>) -> Self {
116 Self {
117 kind: FileChangeKind::Add {
118 content: content.into(),
119 },
120 attribution: None,
121 line_range: None,
122 }
123 }
124
125 pub fn delete(original_content: impl Into<String>) -> Self {
127 Self {
128 kind: FileChangeKind::Delete {
129 original_content: original_content.into(),
130 },
131 attribution: None,
132 line_range: None,
133 }
134 }
135
136 pub fn update(old_content: impl Into<String>, new_content: impl Into<String>) -> Self {
138 Self {
139 kind: FileChangeKind::Update {
140 old_content: old_content.into(),
141 new_content: new_content.into(),
142 },
143 attribution: None,
144 line_range: None,
145 }
146 }
147
148 pub fn rename(
150 new_path: PathBuf,
151 old_content: Option<String>,
152 new_content: Option<String>,
153 ) -> Self {
154 Self {
155 kind: FileChangeKind::Rename {
156 new_path,
157 old_content,
158 new_content,
159 },
160 attribution: None,
161 line_range: None,
162 }
163 }
164
165 pub fn with_attribution(mut self, attribution: ChangeAttribution) -> Self {
167 self.attribution = Some(attribution);
168 self
169 }
170
171 pub fn with_line_range(mut self, start: u32, end: u32) -> Self {
173 self.line_range = Some((start, end));
174 self
175 }
176
177 pub fn new_content(&self) -> Option<&str> {
179 match &self.kind {
180 FileChangeKind::Add { content } => Some(content),
181 FileChangeKind::Update { new_content, .. } => Some(new_content),
182 FileChangeKind::Rename { new_content, .. } => new_content.as_deref(),
183 FileChangeKind::Delete { .. } => None,
184 }
185 }
186
187 pub fn old_content(&self) -> Option<&str> {
189 match &self.kind {
190 FileChangeKind::Delete { original_content } => Some(original_content),
191 FileChangeKind::Update { old_content, .. } => Some(old_content),
192 FileChangeKind::Rename { old_content, .. } => old_content.as_deref(),
193 FileChangeKind::Add { .. } => None,
194 }
195 }
196
197 pub fn is_add(&self) -> bool {
199 matches!(self.kind, FileChangeKind::Add { .. })
200 }
201
202 pub fn is_delete(&self) -> bool {
204 matches!(self.kind, FileChangeKind::Delete { .. })
205 }
206
207 pub fn is_update(&self) -> bool {
209 matches!(self.kind, FileChangeKind::Update { .. })
210 }
211
212 pub fn is_rename(&self) -> bool {
214 matches!(self.kind, FileChangeKind::Rename { .. })
215 }
216
217 pub fn new_line_count(&self) -> usize {
219 self.new_content().map(|c| c.lines().count()).unwrap_or(0)
220 }
221
222 pub fn from_legacy(
226 legacy: &super::tool_handler::FileChange,
227 attribution: Option<ChangeAttribution>,
228 ) -> Self {
229 let kind = match legacy {
230 super::tool_handler::FileChange::Add { content } => FileChangeKind::Add {
231 content: content.clone(),
232 },
233 super::tool_handler::FileChange::Delete => FileChangeKind::Delete {
234 original_content: String::new(), },
236 super::tool_handler::FileChange::Update {
237 old_content,
238 new_content,
239 } => FileChangeKind::Update {
240 old_content: old_content.clone(),
241 new_content: new_content.clone(),
242 },
243 super::tool_handler::FileChange::Rename { new_path, content } => {
244 FileChangeKind::Rename {
245 new_path: new_path.clone(),
246 old_content: None,
247 new_content: content.clone(),
248 }
249 }
250 };
251 Self {
252 kind,
253 attribution,
254 line_range: None,
255 }
256 }
257
258 pub fn to_legacy(&self) -> super::tool_handler::FileChange {
260 match &self.kind {
261 FileChangeKind::Add { content } => super::tool_handler::FileChange::Add {
262 content: content.clone(),
263 },
264 FileChangeKind::Delete { .. } => super::tool_handler::FileChange::Delete,
265 FileChangeKind::Update {
266 old_content,
267 new_content,
268 } => super::tool_handler::FileChange::Update {
269 old_content: old_content.clone(),
270 new_content: new_content.clone(),
271 },
272 FileChangeKind::Rename {
273 new_path,
274 new_content,
275 ..
276 } => super::tool_handler::FileChange::Rename {
277 new_path: new_path.clone(),
278 content: new_content.clone(),
279 },
280 }
281 }
282}
283
284#[derive(Default)]
286pub struct TurnDiffTracker {
287 changes: HashMap<PathBuf, FileChange>,
288 pending_changes: Option<HashMap<PathBuf, FileChange>>,
289 current_attribution: Option<ChangeAttribution>,
291}
292
293impl TurnDiffTracker {
294 pub fn new() -> Self {
295 Self::default()
296 }
297
298 pub fn set_attribution(&mut self, attribution: ChangeAttribution) {
300 self.current_attribution = Some(attribution);
301 }
302
303 pub fn clear_attribution(&mut self) {
305 self.current_attribution = None;
306 }
307
308 pub fn current_attribution(&self) -> Option<&ChangeAttribution> {
310 self.current_attribution.as_ref()
311 }
312
313 pub fn on_patch_begin(&mut self, changes: HashMap<PathBuf, FileChange>) {
317 let changes_with_attribution: HashMap<PathBuf, FileChange> = changes
319 .into_iter()
320 .map(|(path, mut change)| {
321 if change.attribution.is_none() {
322 change.attribution = self.current_attribution.clone();
323 }
324 (path, change)
325 })
326 .collect();
327 self.pending_changes = Some(changes_with_attribution);
328 }
329
330 pub fn on_patch_end(&mut self, success: bool) {
334 if success {
335 if let Some(pending) = self.pending_changes.take() {
336 for (path, change) in pending {
337 self.merge_change(path, change);
338 }
339 }
340 } else {
341 self.pending_changes = None;
342 }
343 }
344
345 fn merge_change(&mut self, path: PathBuf, change: FileChange) {
347 if let Some(existing) = self.changes.get(&path) {
348 let merged = match (&existing.kind, &change.kind) {
350 (FileChangeKind::Add { .. }, FileChangeKind::Update { new_content, .. }) => {
352 FileChange {
353 kind: FileChangeKind::Add {
354 content: new_content.clone(),
355 },
356 attribution: change.attribution.clone().or(existing.attribution.clone()),
357 line_range: change.line_range,
358 }
359 }
360 (FileChangeKind::Add { .. }, FileChangeKind::Delete { .. }) => {
362 self.changes.remove(&path);
363 return;
364 }
365 (
367 FileChangeKind::Update { old_content, .. },
368 FileChangeKind::Update { new_content, .. },
369 ) => FileChange {
370 kind: FileChangeKind::Update {
371 old_content: old_content.clone(),
372 new_content: new_content.clone(),
373 },
374 attribution: change.attribution.clone().or(existing.attribution.clone()),
375 line_range: change.line_range,
376 },
377 (FileChangeKind::Update { old_content, .. }, FileChangeKind::Delete { .. }) => {
379 FileChange {
380 kind: FileChangeKind::Delete {
381 original_content: old_content.clone(),
382 },
383 attribution: change.attribution.clone().or(existing.attribution.clone()),
384 line_range: None,
385 }
386 }
387 (FileChangeKind::Delete { original_content }, FileChangeKind::Add { content }) => {
389 FileChange {
390 kind: FileChangeKind::Update {
391 old_content: original_content.clone(),
392 new_content: content.clone(),
393 },
394 attribution: change.attribution.clone().or(existing.attribution.clone()),
395 line_range: change.line_range,
396 }
397 }
398 _ => change,
400 };
401 self.changes.insert(path, merged);
402 } else {
403 self.changes.insert(path, change);
404 }
405 }
406
407 pub fn changes(&self) -> &HashMap<PathBuf, FileChange> {
409 &self.changes
410 }
411
412 pub fn pending_changes(&self) -> Option<&HashMap<PathBuf, FileChange>> {
414 self.pending_changes.as_ref()
415 }
416
417 pub fn has_changes(&self) -> bool {
419 !self.changes.is_empty()
420 }
421
422 pub fn get_unified_diff(&self) -> String {
424 let mut diff = String::new();
425
426 for (path, change) in &self.changes {
427 let path_str = path.display();
428 match &change.kind {
429 FileChangeKind::Add { content } => {
430 let new_label = path_str.to_string();
431 diff.push_str(&compute_unified_diff_with_labels(
432 "",
433 content,
434 "/dev/null",
435 &new_label,
436 ));
437 }
438 FileChangeKind::Delete { original_content } => {
439 let old_label = path_str.to_string();
440 diff.push_str(&compute_unified_diff_with_labels(
441 original_content,
442 "",
443 &old_label,
444 "/dev/null",
445 ));
446 }
447 FileChangeKind::Update {
448 old_content,
449 new_content,
450 } => {
451 let filename = path_str.to_string();
452 diff.push_str(&compute_unified_diff_with_labels(
453 old_content,
454 new_content,
455 &filename,
456 &filename,
457 ));
458 }
459 FileChangeKind::Rename {
460 new_path,
461 old_content,
462 new_content,
463 } => {
464 if let (Some(old), Some(new)) = (old_content, new_content) {
465 let old_label = path_str.to_string();
466 let new_label = new_path.to_string_lossy();
467 diff.push_str(&compute_unified_diff_with_labels(
468 old, new, &old_label, &new_label,
469 ));
470 }
471 }
472 }
473 diff.push('\n');
474 }
475
476 diff
477 }
478
479 pub fn clear(&mut self) {
481 self.changes.clear();
482 self.pending_changes = None;
483 }
484}
485
486pub type SharedTurnDiffTracker = Arc<RwLock<TurnDiffTracker>>;
488
489pub fn new_shared_tracker() -> SharedTurnDiffTracker {
491 Arc::new(RwLock::new(TurnDiffTracker::new()))
492}
493
494fn compute_unified_diff_with_labels(
496 old: &str,
497 new: &str,
498 old_label: &str,
499 new_label: &str,
500) -> String {
501 let old_label = format!("a/{}", old_label);
502 let new_label = format!("b/{}", new_label);
503 crate::utils::diff::format_unified_diff(
504 old,
505 new,
506 crate::utils::diff::DiffOptions {
507 context_lines: 3,
508 old_label: Some(&old_label),
509 new_label: Some(&new_label),
510 missing_newline_hint: false,
511 },
512 )
513}
514
515#[cfg(test)]
517fn format_addition_diff(content: &str) -> String {
518 compute_unified_diff_with_labels("", content, "file", "file")
519}
520
521#[cfg(test)]
523fn format_deletion_diff(content: &str) -> String {
524 compute_unified_diff_with_labels(content, "", "file", "file")
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn test_on_patch_begin_and_end_success() {
533 let mut tracker = TurnDiffTracker::new();
534
535 let mut changes = HashMap::new();
536 changes.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
537
538 tracker.on_patch_begin(changes);
539 assert!(tracker.pending_changes().is_some());
540 assert!(!tracker.has_changes());
541
542 tracker.on_patch_end(true);
543 assert!(tracker.pending_changes().is_none());
544 assert!(tracker.has_changes());
545 }
546
547 #[test]
548 fn test_on_patch_end_failure() {
549 let mut tracker = TurnDiffTracker::new();
550
551 let mut changes = HashMap::new();
552 changes.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
553
554 tracker.on_patch_begin(changes);
555 tracker.on_patch_end(false);
556
557 assert!(tracker.pending_changes().is_none());
558 assert!(!tracker.has_changes());
559 }
560
561 #[test]
562 fn test_merge_add_then_update() {
563 let mut tracker = TurnDiffTracker::new();
564
565 let mut changes1 = HashMap::new();
567 changes1.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
568 tracker.on_patch_begin(changes1);
569 tracker.on_patch_end(true);
570
571 let mut changes2 = HashMap::new();
573 changes2.insert(
574 PathBuf::from("test.txt"),
575 FileChange::update("hello", "world"),
576 );
577 tracker.on_patch_begin(changes2);
578 tracker.on_patch_end(true);
579
580 let change = tracker.changes().get(&PathBuf::from("test.txt")).unwrap();
582 assert!(change.is_add());
583 assert_eq!(change.new_content(), Some("world"));
584 }
585
586 #[test]
587 fn test_merge_add_then_delete() {
588 let mut tracker = TurnDiffTracker::new();
589
590 let mut changes1 = HashMap::new();
592 changes1.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
593 tracker.on_patch_begin(changes1);
594 tracker.on_patch_end(true);
595
596 let mut changes2 = HashMap::new();
598 changes2.insert(PathBuf::from("test.txt"), FileChange::delete("hello"));
599 tracker.on_patch_begin(changes2);
600 tracker.on_patch_end(true);
601
602 assert!(!tracker.has_changes());
604 }
605
606 #[test]
607 fn test_get_unified_diff() {
608 let mut tracker = TurnDiffTracker::new();
609
610 let mut changes = HashMap::new();
611 changes.insert(PathBuf::from("new.txt"), FileChange::add("line1\nline2"));
612 tracker.on_patch_begin(changes);
613 tracker.on_patch_end(true);
614
615 let diff = tracker.get_unified_diff();
616 assert!(diff.contains("--- "));
617 assert!(diff.contains("+++ "));
618 assert!(diff.contains("new.txt"));
619 assert!(diff.contains("+line1"));
620 assert!(diff.contains("+line2"));
621 }
622
623 #[test]
624 fn test_attribution_propagation() {
625 let mut tracker = TurnDiffTracker::new();
626 tracker.set_attribution(ChangeAttribution::ai("claude-opus-4", "anthropic"));
627
628 let mut changes = HashMap::new();
629 changes.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
630 tracker.on_patch_begin(changes);
631 tracker.on_patch_end(true);
632
633 let change = tracker.changes().get(&PathBuf::from("test.txt")).unwrap();
634 assert!(change.attribution.is_some());
635 let attr = change.attribution.as_ref().unwrap();
636 assert_eq!(attr.contributor_type, "ai");
637 assert_eq!(attr.model_id, Some("claude-opus-4".to_string()));
638 }
639
640 #[test]
641 fn test_format_addition_diff() {
642 let diff = format_addition_diff("line1\nline2");
643 assert!(diff.contains("@@"));
644 assert!(diff.contains("+line1"));
645 assert!(diff.contains("+line2"));
646 }
647
648 #[test]
649 fn test_format_deletion_diff() {
650 let diff = format_deletion_diff("line1\nline2");
651 assert!(diff.contains("@@"));
652 assert!(diff.contains("-line1"));
653 assert!(diff.contains("-line2"));
654 }
655
656 #[test]
657 fn test_compute_unified_diff() {
658 let old = "line1\nline2\nline3";
659 let new = "line1\nmodified\nline3";
660 let diff = compute_unified_diff_with_labels(old, new, "file.txt", "file.txt");
661
662 assert!(diff.contains(" line1"));
663 assert!(diff.contains("-line2"));
664 assert!(diff.contains("+modified"));
665 assert!(diff.contains(" line3"));
666 assert!(diff.starts_with("--- a/"));
667 }
668
669 #[test]
670 fn test_file_change_accessors() {
671 let add = FileChange::add("hello");
672 assert_eq!(add.new_content(), Some("hello"));
673 assert_eq!(add.old_content(), None);
674 assert!(add.is_add());
675
676 let delete = FileChange::delete("goodbye");
677 assert_eq!(delete.new_content(), None);
678 assert_eq!(delete.old_content(), Some("goodbye"));
679 assert!(delete.is_delete());
680
681 let update = FileChange::update("old", "new");
682 assert_eq!(update.new_content(), Some("new"));
683 assert_eq!(update.old_content(), Some("old"));
684 assert!(update.is_update());
685 }
686
687 #[test]
688 fn test_normalized_model_id() {
689 let attr = ChangeAttribution::ai("claude-opus-4", "anthropic");
690 assert_eq!(
691 attr.normalized_model_id(),
692 Some("anthropic/claude-opus-4".to_string())
693 );
694
695 let attr2 = ChangeAttribution::ai("anthropic/claude-opus-4", "anthropic");
696 assert_eq!(
697 attr2.normalized_model_id(),
698 Some("anthropic/claude-opus-4".to_string())
699 );
700 }
701
702 #[tokio::test]
703 async fn test_shared_tracker() {
704 let tracker = new_shared_tracker();
705
706 {
707 let mut t = tracker.write().await;
708 let mut changes = HashMap::new();
709 changes.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
710 t.on_patch_begin(changes);
711 t.on_patch_end(true);
712 }
713
714 {
715 let t = tracker.read().await;
716 assert!(t.has_changes());
717 }
718 }
719
720 #[test]
721 fn test_file_change_serialization() {
722 let change = FileChange::add("fn main() {}")
723 .with_attribution(ChangeAttribution::ai("claude-opus-4", "anthropic"))
724 .with_line_range(1, 5);
725
726 let json = serde_json::to_string_pretty(&change).unwrap();
727 assert!(json.contains("\"type\": \"add\""));
728 assert!(json.contains("\"content\": \"fn main() {}\""));
729 assert!(json.contains("\"contributor_type\": \"ai\""));
730 assert!(json.contains("\"model_id\": \"claude-opus-4\""));
731
732 let restored: FileChange = serde_json::from_str(&json).unwrap();
734 assert!(restored.is_add());
735 assert_eq!(restored.new_content(), Some("fn main() {}"));
736 assert!(restored.attribution.is_some());
737 }
738
739 #[test]
740 fn test_change_attribution_serialization() {
741 let attr = ChangeAttribution::ai("gpt-5", "openai").with_session("session-abc", 3);
742
743 let json = serde_json::to_string(&attr).unwrap();
744 let restored: ChangeAttribution = serde_json::from_str(&json).unwrap();
745
746 assert_eq!(restored.model_id, Some("gpt-5".to_string()));
747 assert_eq!(restored.provider, Some("openai".to_string()));
748 assert_eq!(restored.session_id, Some("session-abc".to_string()));
749 assert_eq!(restored.turn_number, Some(3));
750 assert_eq!(restored.contributor_type, "ai");
751 }
752}