Skip to main content

autom8/
knowledge.rs

1//! Project knowledge tracking for cumulative context across agent runs.
2//!
3//! This module provides data structures for tracking what agents learn and
4//! accomplish during implementation runs. The knowledge is accumulated across
5//! multiple story implementations and can be injected into subsequent agent
6//! prompts to provide richer context.
7
8use crate::git::DiffEntry;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::path::PathBuf;
12
13/// Cumulative project knowledge tracked across agent runs.
14///
15/// This struct combines two sources of truth:
16/// - Git diff data for empirical knowledge of file changes
17/// - Agent-provided semantic information about decisions and patterns
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19#[serde(rename_all = "camelCase")]
20pub struct ProjectKnowledge {
21    /// Known files and their metadata, keyed by path
22    pub files: HashMap<PathBuf, FileInfo>,
23
24    /// Architectural and implementation decisions made during the run
25    pub decisions: Vec<Decision>,
26
27    /// Code patterns established or discovered during the run
28    pub patterns: Vec<Pattern>,
29
30    /// Changes made for each completed story
31    pub story_changes: Vec<StoryChanges>,
32
33    /// The baseline commit hash when the run started (for git diff calculations)
34    pub baseline_commit: Option<String>,
35}
36
37impl ProjectKnowledge {
38    /// Returns all files touched by any story in this run.
39    ///
40    /// This includes files that were created, modified, or deleted across
41    /// all completed stories. Used to filter out changes from other sources.
42    pub fn our_files(&self) -> HashSet<&PathBuf> {
43        let mut files = HashSet::new();
44
45        for story in &self.story_changes {
46            // Add created files
47            for change in &story.files_created {
48                files.insert(&change.path);
49            }
50
51            // Add modified files
52            for change in &story.files_modified {
53                files.insert(&change.path);
54            }
55
56            // Add deleted files
57            for path in &story.files_deleted {
58                files.insert(path);
59            }
60        }
61
62        files
63    }
64
65    /// Filter diff entries to only include files that autom8 agents touched.
66    ///
67    /// This method filters out changes from external sources by only including
68    /// files that are either:
69    /// - New to the project (DiffStatus::Added)
70    /// - Already in the set of files we've touched in this run
71    ///
72    /// # Arguments
73    /// * `all_changes` - All diff entries to filter
74    ///
75    /// # Returns
76    /// Filtered list of diff entries containing only our changes
77    pub fn filter_our_changes(&self, all_changes: &[DiffEntry]) -> Vec<DiffEntry> {
78        let our_files = self.our_files();
79
80        all_changes
81            .iter()
82            .filter(|entry| {
83                // Include if it's a new file (we created it)
84                if entry.status == crate::git::DiffStatus::Added {
85                    return true;
86                }
87
88                // Include if it's in our files set (we've touched it before)
89                our_files.contains(&entry.path)
90            })
91            .cloned()
92            .collect()
93    }
94}
95
96/// Metadata about a known file in the project.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct FileInfo {
100    /// Brief description of the file's purpose
101    pub purpose: String,
102
103    /// Key symbols (functions, types, constants) defined in this file
104    pub key_symbols: Vec<String>,
105
106    /// IDs of stories that have touched this file
107    pub touched_by: Vec<String>,
108
109    /// Number of lines in the file
110    pub line_count: u32,
111}
112
113/// An architectural or implementation decision made during the run.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct Decision {
117    /// The story ID that made this decision
118    pub story_id: String,
119
120    /// The topic or area this decision relates to
121    pub topic: String,
122
123    /// The choice that was made
124    pub choice: String,
125
126    /// Why this choice was made
127    pub rationale: String,
128}
129
130/// A code pattern established or discovered during the run.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct Pattern {
134    /// The story ID that established this pattern
135    pub story_id: String,
136
137    /// Description of the pattern
138    pub description: String,
139
140    /// An example file that demonstrates this pattern
141    pub example_file: Option<PathBuf>,
142}
143
144/// Changes made while implementing a specific story.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct StoryChanges {
148    /// The story ID these changes belong to
149    pub story_id: String,
150
151    /// Files created during this story
152    pub files_created: Vec<FileChange>,
153
154    /// Files modified during this story
155    pub files_modified: Vec<FileChange>,
156
157    /// Files deleted during this story
158    pub files_deleted: Vec<PathBuf>,
159
160    /// The commit hash for these changes (if committed)
161    pub commit_hash: Option<String>,
162}
163
164/// Information about a file change (creation or modification).
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct FileChange {
168    /// Path to the changed file
169    pub path: PathBuf,
170
171    /// Number of lines added
172    pub additions: u32,
173
174    /// Number of lines deleted
175    pub deletions: u32,
176
177    /// Brief description of the file's purpose (agent-provided)
178    pub purpose: Option<String>,
179
180    /// Key symbols added or modified (agent-provided)
181    pub key_symbols: Vec<String>,
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    // ===========================================
189    // ProjectKnowledge tests
190    // ===========================================
191
192    #[test]
193    fn test_project_knowledge_default() {
194        let knowledge = ProjectKnowledge::default();
195        assert!(knowledge.files.is_empty());
196        assert!(knowledge.decisions.is_empty());
197        assert!(knowledge.patterns.is_empty());
198        assert!(knowledge.story_changes.is_empty());
199        assert!(knowledge.baseline_commit.is_none());
200    }
201
202    #[test]
203    fn test_project_knowledge_debug_impl() {
204        let knowledge = ProjectKnowledge::default();
205        let debug_str = format!("{:?}", knowledge);
206        assert!(debug_str.contains("ProjectKnowledge"));
207    }
208
209    #[test]
210    fn test_project_knowledge_clone() {
211        let mut knowledge = ProjectKnowledge::default();
212        knowledge.baseline_commit = Some("abc123".to_string());
213        let cloned = knowledge.clone();
214        assert_eq!(cloned.baseline_commit, Some("abc123".to_string()));
215    }
216
217    #[test]
218    fn test_project_knowledge_serialization_roundtrip() {
219        let mut knowledge = ProjectKnowledge::default();
220        knowledge.baseline_commit = Some("abc123".to_string());
221        knowledge.decisions.push(Decision {
222            story_id: "US-001".to_string(),
223            topic: "Architecture".to_string(),
224            choice: "Use modules".to_string(),
225            rationale: "Better organization".to_string(),
226        });
227
228        let json = serde_json::to_string(&knowledge).unwrap();
229        let deserialized: ProjectKnowledge = serde_json::from_str(&json).unwrap();
230
231        assert_eq!(deserialized.baseline_commit, Some("abc123".to_string()));
232        assert_eq!(deserialized.decisions.len(), 1);
233        assert_eq!(deserialized.decisions[0].story_id, "US-001");
234    }
235
236    #[test]
237    fn test_project_knowledge_camel_case_serialization() {
238        let knowledge = ProjectKnowledge {
239            baseline_commit: Some("abc".to_string()),
240            story_changes: vec![StoryChanges {
241                story_id: "US-001".to_string(),
242                files_created: vec![],
243                files_modified: vec![],
244                files_deleted: vec![],
245                commit_hash: None,
246            }],
247            ..Default::default()
248        };
249
250        let json = serde_json::to_string(&knowledge).unwrap();
251        assert!(json.contains("baselineCommit"));
252        assert!(json.contains("storyChanges"));
253        assert!(json.contains("storyId"));
254        assert!(json.contains("filesCreated"));
255        assert!(json.contains("filesModified"));
256        assert!(json.contains("filesDeleted"));
257        assert!(json.contains("commitHash"));
258    }
259
260    // ===========================================
261    // FileInfo tests
262    // ===========================================
263
264    #[test]
265    fn test_file_info_creation() {
266        let file_info = FileInfo {
267            purpose: "Main entry point".to_string(),
268            key_symbols: vec!["main".to_string(), "run".to_string()],
269            touched_by: vec!["US-001".to_string()],
270            line_count: 150,
271        };
272
273        assert_eq!(file_info.purpose, "Main entry point");
274        assert_eq!(file_info.key_symbols.len(), 2);
275        assert_eq!(file_info.touched_by.len(), 1);
276        assert_eq!(file_info.line_count, 150);
277    }
278
279    #[test]
280    fn test_file_info_serialization() {
281        let file_info = FileInfo {
282            purpose: "Test file".to_string(),
283            key_symbols: vec!["test_fn".to_string()],
284            touched_by: vec!["US-001".to_string()],
285            line_count: 50,
286        };
287
288        let json = serde_json::to_string(&file_info).unwrap();
289        assert!(json.contains("keySymbols"));
290        assert!(json.contains("touchedBy"));
291        assert!(json.contains("lineCount"));
292
293        let deserialized: FileInfo = serde_json::from_str(&json).unwrap();
294        assert_eq!(deserialized.purpose, "Test file");
295        assert_eq!(deserialized.line_count, 50);
296    }
297
298    // ===========================================
299    // Decision tests
300    // ===========================================
301
302    #[test]
303    fn test_decision_creation() {
304        let decision = Decision {
305            story_id: "US-001".to_string(),
306            topic: "Database".to_string(),
307            choice: "SQLite".to_string(),
308            rationale: "Simple, embedded, no setup".to_string(),
309        };
310
311        assert_eq!(decision.story_id, "US-001");
312        assert_eq!(decision.topic, "Database");
313        assert_eq!(decision.choice, "SQLite");
314        assert_eq!(decision.rationale, "Simple, embedded, no setup");
315    }
316
317    #[test]
318    fn test_decision_serialization() {
319        let decision = Decision {
320            story_id: "US-002".to_string(),
321            topic: "Auth".to_string(),
322            choice: "JWT".to_string(),
323            rationale: "Stateless".to_string(),
324        };
325
326        let json = serde_json::to_string(&decision).unwrap();
327        assert!(json.contains("storyId"));
328
329        let deserialized: Decision = serde_json::from_str(&json).unwrap();
330        assert_eq!(deserialized.story_id, "US-002");
331    }
332
333    // ===========================================
334    // Pattern tests
335    // ===========================================
336
337    #[test]
338    fn test_pattern_creation_with_example() {
339        let pattern = Pattern {
340            story_id: "US-001".to_string(),
341            description: "Use Result<T, Error> for all fallible operations".to_string(),
342            example_file: Some(PathBuf::from("src/runner.rs")),
343        };
344
345        assert_eq!(pattern.story_id, "US-001");
346        assert!(pattern.example_file.is_some());
347    }
348
349    #[test]
350    fn test_pattern_creation_without_example() {
351        let pattern = Pattern {
352            story_id: "US-001".to_string(),
353            description: "Use snake_case for function names".to_string(),
354            example_file: None,
355        };
356
357        assert!(pattern.example_file.is_none());
358    }
359
360    #[test]
361    fn test_pattern_serialization() {
362        let pattern = Pattern {
363            story_id: "US-001".to_string(),
364            description: "Test pattern".to_string(),
365            example_file: Some(PathBuf::from("src/lib.rs")),
366        };
367
368        let json = serde_json::to_string(&pattern).unwrap();
369        assert!(json.contains("storyId"));
370        assert!(json.contains("exampleFile"));
371
372        let deserialized: Pattern = serde_json::from_str(&json).unwrap();
373        assert_eq!(deserialized.example_file, Some(PathBuf::from("src/lib.rs")));
374    }
375
376    // ===========================================
377    // StoryChanges tests
378    // ===========================================
379
380    #[test]
381    fn test_story_changes_creation() {
382        let changes = StoryChanges {
383            story_id: "US-001".to_string(),
384            files_created: vec![FileChange {
385                path: PathBuf::from("src/new.rs"),
386                additions: 100,
387                deletions: 0,
388                purpose: Some("New module".to_string()),
389                key_symbols: vec!["NewStruct".to_string()],
390            }],
391            files_modified: vec![FileChange {
392                path: PathBuf::from("src/lib.rs"),
393                additions: 5,
394                deletions: 0,
395                purpose: None,
396                key_symbols: vec![],
397            }],
398            files_deleted: vec![PathBuf::from("src/old.rs")],
399            commit_hash: Some("def456".to_string()),
400        };
401
402        assert_eq!(changes.story_id, "US-001");
403        assert_eq!(changes.files_created.len(), 1);
404        assert_eq!(changes.files_modified.len(), 1);
405        assert_eq!(changes.files_deleted.len(), 1);
406        assert!(changes.commit_hash.is_some());
407    }
408
409    #[test]
410    fn test_story_changes_without_commit() {
411        let changes = StoryChanges {
412            story_id: "US-001".to_string(),
413            files_created: vec![],
414            files_modified: vec![],
415            files_deleted: vec![],
416            commit_hash: None,
417        };
418
419        assert!(changes.commit_hash.is_none());
420    }
421
422    #[test]
423    fn test_story_changes_serialization() {
424        let changes = StoryChanges {
425            story_id: "US-001".to_string(),
426            files_created: vec![],
427            files_modified: vec![],
428            files_deleted: vec![],
429            commit_hash: Some("abc".to_string()),
430        };
431
432        let json = serde_json::to_string(&changes).unwrap();
433        assert!(json.contains("storyId"));
434        assert!(json.contains("filesCreated"));
435        assert!(json.contains("filesModified"));
436        assert!(json.contains("filesDeleted"));
437        assert!(json.contains("commitHash"));
438    }
439
440    // ===========================================
441    // FileChange tests
442    // ===========================================
443
444    #[test]
445    fn test_file_change_creation() {
446        let change = FileChange {
447            path: PathBuf::from("src/test.rs"),
448            additions: 50,
449            deletions: 10,
450            purpose: Some("Test utilities".to_string()),
451            key_symbols: vec!["test_helper".to_string(), "setup".to_string()],
452        };
453
454        assert_eq!(change.path, PathBuf::from("src/test.rs"));
455        assert_eq!(change.additions, 50);
456        assert_eq!(change.deletions, 10);
457        assert!(change.purpose.is_some());
458        assert_eq!(change.key_symbols.len(), 2);
459    }
460
461    #[test]
462    fn test_file_change_minimal() {
463        let change = FileChange {
464            path: PathBuf::from("src/lib.rs"),
465            additions: 1,
466            deletions: 0,
467            purpose: None,
468            key_symbols: vec![],
469        };
470
471        assert!(change.purpose.is_none());
472        assert!(change.key_symbols.is_empty());
473    }
474
475    #[test]
476    fn test_file_change_serialization() {
477        let change = FileChange {
478            path: PathBuf::from("src/test.rs"),
479            additions: 10,
480            deletions: 5,
481            purpose: Some("Test".to_string()),
482            key_symbols: vec!["sym".to_string()],
483        };
484
485        let json = serde_json::to_string(&change).unwrap();
486        assert!(json.contains("keySymbols"));
487
488        let deserialized: FileChange = serde_json::from_str(&json).unwrap();
489        assert_eq!(deserialized.additions, 10);
490        assert_eq!(deserialized.deletions, 5);
491    }
492
493    // ===========================================
494    // Integration tests
495    // ===========================================
496
497    #[test]
498    fn test_project_knowledge_with_files() {
499        let mut knowledge = ProjectKnowledge::default();
500
501        knowledge.files.insert(
502            PathBuf::from("src/main.rs"),
503            FileInfo {
504                purpose: "Application entry point".to_string(),
505                key_symbols: vec!["main".to_string()],
506                touched_by: vec!["US-001".to_string()],
507                line_count: 100,
508            },
509        );
510
511        assert_eq!(knowledge.files.len(), 1);
512        let file_info = knowledge.files.get(&PathBuf::from("src/main.rs")).unwrap();
513        assert_eq!(file_info.purpose, "Application entry point");
514    }
515
516    #[test]
517    fn test_full_knowledge_serialization_roundtrip() {
518        let mut knowledge = ProjectKnowledge::default();
519        knowledge.baseline_commit = Some("baseline123".to_string());
520
521        knowledge.files.insert(
522            PathBuf::from("src/lib.rs"),
523            FileInfo {
524                purpose: "Library root".to_string(),
525                key_symbols: vec!["mod".to_string()],
526                touched_by: vec!["US-001".to_string(), "US-002".to_string()],
527                line_count: 50,
528            },
529        );
530
531        knowledge.decisions.push(Decision {
532            story_id: "US-001".to_string(),
533            topic: "Error handling".to_string(),
534            choice: "thiserror crate".to_string(),
535            rationale: "Clean error types".to_string(),
536        });
537
538        knowledge.patterns.push(Pattern {
539            story_id: "US-001".to_string(),
540            description: "Use ? operator for error propagation".to_string(),
541            example_file: Some(PathBuf::from("src/runner.rs")),
542        });
543
544        knowledge.story_changes.push(StoryChanges {
545            story_id: "US-001".to_string(),
546            files_created: vec![FileChange {
547                path: PathBuf::from("src/knowledge.rs"),
548                additions: 200,
549                deletions: 0,
550                purpose: Some("Knowledge tracking".to_string()),
551                key_symbols: vec!["ProjectKnowledge".to_string()],
552            }],
553            files_modified: vec![FileChange {
554                path: PathBuf::from("src/lib.rs"),
555                additions: 1,
556                deletions: 0,
557                purpose: None,
558                key_symbols: vec![],
559            }],
560            files_deleted: vec![],
561            commit_hash: Some("commit123".to_string()),
562        });
563
564        // Serialize
565        let json = serde_json::to_string_pretty(&knowledge).unwrap();
566
567        // Deserialize
568        let deserialized: ProjectKnowledge = serde_json::from_str(&json).unwrap();
569
570        // Verify all fields preserved
571        assert_eq!(
572            deserialized.baseline_commit,
573            Some("baseline123".to_string())
574        );
575        assert_eq!(deserialized.files.len(), 1);
576        assert_eq!(deserialized.decisions.len(), 1);
577        assert_eq!(deserialized.patterns.len(), 1);
578        assert_eq!(deserialized.story_changes.len(), 1);
579
580        // Verify nested fields
581        let file_info = deserialized
582            .files
583            .get(&PathBuf::from("src/lib.rs"))
584            .unwrap();
585        assert_eq!(file_info.touched_by.len(), 2);
586
587        let story_changes = &deserialized.story_changes[0];
588        assert_eq!(story_changes.files_created.len(), 1);
589        assert_eq!(story_changes.files_created[0].additions, 200);
590    }
591
592    // ===========================================
593    // US-010: our_files() and filter_our_changes() tests
594    // ===========================================
595
596    #[test]
597    fn test_our_files_empty_knowledge() {
598        let knowledge = ProjectKnowledge::default();
599        let files = knowledge.our_files();
600        assert!(files.is_empty());
601    }
602
603    #[test]
604    fn test_our_files_with_created_files() {
605        let mut knowledge = ProjectKnowledge::default();
606        knowledge.story_changes.push(StoryChanges {
607            story_id: "US-001".to_string(),
608            files_created: vec![FileChange {
609                path: PathBuf::from("src/new.rs"),
610                additions: 100,
611                deletions: 0,
612                purpose: None,
613                key_symbols: vec![],
614            }],
615            files_modified: vec![],
616            files_deleted: vec![],
617            commit_hash: None,
618        });
619
620        let files = knowledge.our_files();
621        assert_eq!(files.len(), 1);
622        assert!(files.contains(&PathBuf::from("src/new.rs")));
623    }
624
625    #[test]
626    fn test_our_files_with_modified_files() {
627        let mut knowledge = ProjectKnowledge::default();
628        knowledge.story_changes.push(StoryChanges {
629            story_id: "US-001".to_string(),
630            files_created: vec![],
631            files_modified: vec![FileChange {
632                path: PathBuf::from("src/lib.rs"),
633                additions: 10,
634                deletions: 5,
635                purpose: None,
636                key_symbols: vec![],
637            }],
638            files_deleted: vec![],
639            commit_hash: None,
640        });
641
642        let files = knowledge.our_files();
643        assert_eq!(files.len(), 1);
644        assert!(files.contains(&PathBuf::from("src/lib.rs")));
645    }
646
647    #[test]
648    fn test_our_files_with_deleted_files() {
649        let mut knowledge = ProjectKnowledge::default();
650        knowledge.story_changes.push(StoryChanges {
651            story_id: "US-001".to_string(),
652            files_created: vec![],
653            files_modified: vec![],
654            files_deleted: vec![PathBuf::from("src/old.rs")],
655            commit_hash: None,
656        });
657
658        let files = knowledge.our_files();
659        assert_eq!(files.len(), 1);
660        assert!(files.contains(&PathBuf::from("src/old.rs")));
661    }
662
663    #[test]
664    fn test_our_files_multiple_stories() {
665        let mut knowledge = ProjectKnowledge::default();
666
667        // First story
668        knowledge.story_changes.push(StoryChanges {
669            story_id: "US-001".to_string(),
670            files_created: vec![FileChange {
671                path: PathBuf::from("src/a.rs"),
672                additions: 50,
673                deletions: 0,
674                purpose: None,
675                key_symbols: vec![],
676            }],
677            files_modified: vec![FileChange {
678                path: PathBuf::from("src/lib.rs"),
679                additions: 1,
680                deletions: 0,
681                purpose: None,
682                key_symbols: vec![],
683            }],
684            files_deleted: vec![],
685            commit_hash: None,
686        });
687
688        // Second story
689        knowledge.story_changes.push(StoryChanges {
690            story_id: "US-002".to_string(),
691            files_created: vec![FileChange {
692                path: PathBuf::from("src/b.rs"),
693                additions: 30,
694                deletions: 0,
695                purpose: None,
696                key_symbols: vec![],
697            }],
698            files_modified: vec![FileChange {
699                path: PathBuf::from("src/lib.rs"),
700                additions: 2,
701                deletions: 0,
702                purpose: None,
703                key_symbols: vec![],
704            }],
705            files_deleted: vec![PathBuf::from("src/old.rs")],
706            commit_hash: None,
707        });
708
709        let files = knowledge.our_files();
710        // Should have: src/a.rs, src/lib.rs, src/b.rs, src/old.rs
711        // src/lib.rs appears twice but HashSet deduplicates
712        assert_eq!(files.len(), 4);
713        assert!(files.contains(&PathBuf::from("src/a.rs")));
714        assert!(files.contains(&PathBuf::from("src/b.rs")));
715        assert!(files.contains(&PathBuf::from("src/lib.rs")));
716        assert!(files.contains(&PathBuf::from("src/old.rs")));
717    }
718
719    #[test]
720    fn test_our_files_deduplicates() {
721        let mut knowledge = ProjectKnowledge::default();
722
723        // Same file modified in two stories
724        knowledge.story_changes.push(StoryChanges {
725            story_id: "US-001".to_string(),
726            files_created: vec![],
727            files_modified: vec![FileChange {
728                path: PathBuf::from("src/lib.rs"),
729                additions: 10,
730                deletions: 0,
731                purpose: None,
732                key_symbols: vec![],
733            }],
734            files_deleted: vec![],
735            commit_hash: None,
736        });
737
738        knowledge.story_changes.push(StoryChanges {
739            story_id: "US-002".to_string(),
740            files_created: vec![],
741            files_modified: vec![FileChange {
742                path: PathBuf::from("src/lib.rs"),
743                additions: 5,
744                deletions: 2,
745                purpose: None,
746                key_symbols: vec![],
747            }],
748            files_deleted: vec![],
749            commit_hash: None,
750        });
751
752        let files = knowledge.our_files();
753        // Should only have one entry for src/lib.rs
754        assert_eq!(files.len(), 1);
755        assert!(files.contains(&PathBuf::from("src/lib.rs")));
756    }
757
758    #[test]
759    fn test_filter_our_changes_empty_knowledge() {
760        use crate::git::{DiffEntry, DiffStatus};
761
762        let knowledge = ProjectKnowledge::default();
763        let all_changes = vec![
764            DiffEntry {
765                path: PathBuf::from("src/external.rs"),
766                additions: 10,
767                deletions: 0,
768                status: DiffStatus::Modified,
769            },
770            DiffEntry {
771                path: PathBuf::from("src/new.rs"),
772                additions: 50,
773                deletions: 0,
774                status: DiffStatus::Added,
775            },
776        ];
777
778        let filtered = knowledge.filter_our_changes(&all_changes);
779
780        // With empty knowledge, only Added files should pass through
781        assert_eq!(filtered.len(), 1);
782        assert_eq!(filtered[0].path, PathBuf::from("src/new.rs"));
783    }
784
785    #[test]
786    fn test_filter_our_changes_includes_our_modified_files() {
787        use crate::git::{DiffEntry, DiffStatus};
788
789        let mut knowledge = ProjectKnowledge::default();
790        knowledge.story_changes.push(StoryChanges {
791            story_id: "US-001".to_string(),
792            files_created: vec![FileChange {
793                path: PathBuf::from("src/our_file.rs"),
794                additions: 100,
795                deletions: 0,
796                purpose: None,
797                key_symbols: vec![],
798            }],
799            files_modified: vec![],
800            files_deleted: vec![],
801            commit_hash: None,
802        });
803
804        let all_changes = vec![
805            DiffEntry {
806                path: PathBuf::from("src/our_file.rs"),
807                additions: 10,
808                deletions: 5,
809                status: DiffStatus::Modified,
810            },
811            DiffEntry {
812                path: PathBuf::from("src/external.rs"),
813                additions: 20,
814                deletions: 0,
815                status: DiffStatus::Modified,
816            },
817        ];
818
819        let filtered = knowledge.filter_our_changes(&all_changes);
820
821        // Only our_file.rs should be included (external.rs is modified but not in our_files)
822        assert_eq!(filtered.len(), 1);
823        assert_eq!(filtered[0].path, PathBuf::from("src/our_file.rs"));
824    }
825
826    #[test]
827    fn test_filter_our_changes_includes_new_files() {
828        use crate::git::{DiffEntry, DiffStatus};
829
830        let mut knowledge = ProjectKnowledge::default();
831        knowledge.story_changes.push(StoryChanges {
832            story_id: "US-001".to_string(),
833            files_created: vec![FileChange {
834                path: PathBuf::from("src/old_new.rs"),
835                additions: 50,
836                deletions: 0,
837                purpose: None,
838                key_symbols: vec![],
839            }],
840            files_modified: vec![],
841            files_deleted: vec![],
842            commit_hash: None,
843        });
844
845        let all_changes = vec![
846            DiffEntry {
847                path: PathBuf::from("src/brand_new.rs"),
848                additions: 100,
849                deletions: 0,
850                status: DiffStatus::Added,
851            },
852            DiffEntry {
853                path: PathBuf::from("src/external.rs"),
854                additions: 20,
855                deletions: 10,
856                status: DiffStatus::Modified,
857            },
858        ];
859
860        let filtered = knowledge.filter_our_changes(&all_changes);
861
862        // brand_new.rs should be included because it's Added (new file)
863        // external.rs should be excluded because it's Modified but not in our_files
864        assert_eq!(filtered.len(), 1);
865        assert_eq!(filtered[0].path, PathBuf::from("src/brand_new.rs"));
866    }
867
868    #[test]
869    fn test_filter_our_changes_excludes_external_modifications() {
870        use crate::git::{DiffEntry, DiffStatus};
871
872        let mut knowledge = ProjectKnowledge::default();
873        knowledge.story_changes.push(StoryChanges {
874            story_id: "US-001".to_string(),
875            files_created: vec![FileChange {
876                path: PathBuf::from("src/our.rs"),
877                additions: 50,
878                deletions: 0,
879                purpose: None,
880                key_symbols: vec![],
881            }],
882            files_modified: vec![],
883            files_deleted: vec![],
884            commit_hash: None,
885        });
886
887        let all_changes = vec![
888            DiffEntry {
889                path: PathBuf::from("package-lock.json"),
890                additions: 1000,
891                deletions: 500,
892                status: DiffStatus::Modified,
893            },
894            DiffEntry {
895                path: PathBuf::from(".env"),
896                additions: 1,
897                deletions: 0,
898                status: DiffStatus::Modified,
899            },
900        ];
901
902        let filtered = knowledge.filter_our_changes(&all_changes);
903
904        // Neither file is in our_files and neither is Added, so both excluded
905        assert!(filtered.is_empty());
906    }
907
908    #[test]
909    fn test_filter_our_changes_complex_scenario() {
910        use crate::git::{DiffEntry, DiffStatus};
911
912        let mut knowledge = ProjectKnowledge::default();
913
914        // Story 1 created src/a.rs and modified src/lib.rs
915        knowledge.story_changes.push(StoryChanges {
916            story_id: "US-001".to_string(),
917            files_created: vec![FileChange {
918                path: PathBuf::from("src/a.rs"),
919                additions: 50,
920                deletions: 0,
921                purpose: None,
922                key_symbols: vec![],
923            }],
924            files_modified: vec![FileChange {
925                path: PathBuf::from("src/lib.rs"),
926                additions: 1,
927                deletions: 0,
928                purpose: None,
929                key_symbols: vec![],
930            }],
931            files_deleted: vec![],
932            commit_hash: None,
933        });
934
935        let all_changes = vec![
936            // Our file, modified further
937            DiffEntry {
938                path: PathBuf::from("src/a.rs"),
939                additions: 10,
940                deletions: 5,
941                status: DiffStatus::Modified,
942            },
943            // Our file (modified before)
944            DiffEntry {
945                path: PathBuf::from("src/lib.rs"),
946                additions: 3,
947                deletions: 1,
948                status: DiffStatus::Modified,
949            },
950            // New file in this story
951            DiffEntry {
952                path: PathBuf::from("src/b.rs"),
953                additions: 100,
954                deletions: 0,
955                status: DiffStatus::Added,
956            },
957            // External modification
958            DiffEntry {
959                path: PathBuf::from("Cargo.lock"),
960                additions: 500,
961                deletions: 100,
962                status: DiffStatus::Modified,
963            },
964            // External new file
965            DiffEntry {
966                path: PathBuf::from(".github/workflows/ci.yml"),
967                additions: 50,
968                deletions: 0,
969                status: DiffStatus::Added,
970            },
971        ];
972
973        let filtered = knowledge.filter_our_changes(&all_changes);
974
975        // Should include:
976        // - src/a.rs (in our_files)
977        // - src/lib.rs (in our_files)
978        // - src/b.rs (Added - new file)
979        // - .github/workflows/ci.yml (Added - new file, even if external)
980        // Should exclude:
981        // - Cargo.lock (Modified, not in our_files)
982        assert_eq!(filtered.len(), 4);
983
984        let paths: Vec<_> = filtered.iter().map(|e| e.path.clone()).collect();
985        assert!(paths.contains(&PathBuf::from("src/a.rs")));
986        assert!(paths.contains(&PathBuf::from("src/lib.rs")));
987        assert!(paths.contains(&PathBuf::from("src/b.rs")));
988        assert!(paths.contains(&PathBuf::from(".github/workflows/ci.yml")));
989        assert!(!paths.contains(&PathBuf::from("Cargo.lock")));
990    }
991
992    #[test]
993    fn test_filter_our_changes_with_deleted_file() {
994        use crate::git::{DiffEntry, DiffStatus};
995
996        let mut knowledge = ProjectKnowledge::default();
997        knowledge.story_changes.push(StoryChanges {
998            story_id: "US-001".to_string(),
999            files_created: vec![],
1000            files_modified: vec![FileChange {
1001                path: PathBuf::from("src/to_delete.rs"),
1002                additions: 5,
1003                deletions: 0,
1004                purpose: None,
1005                key_symbols: vec![],
1006            }],
1007            files_deleted: vec![],
1008            commit_hash: None,
1009        });
1010
1011        let all_changes = vec![DiffEntry {
1012            path: PathBuf::from("src/to_delete.rs"),
1013            additions: 0,
1014            deletions: 50,
1015            status: DiffStatus::Deleted,
1016        }];
1017
1018        let filtered = knowledge.filter_our_changes(&all_changes);
1019
1020        // Deleted file is in our_files, so should be included
1021        assert_eq!(filtered.len(), 1);
1022        assert_eq!(filtered[0].path, PathBuf::from("src/to_delete.rs"));
1023        assert_eq!(filtered[0].status, DiffStatus::Deleted);
1024    }
1025
1026    #[test]
1027    fn test_filter_our_changes_empty_input() {
1028        let knowledge = ProjectKnowledge::default();
1029        let filtered = knowledge.filter_our_changes(&[]);
1030        assert!(filtered.is_empty());
1031    }
1032}