use crate::git::DiffEntry;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ProjectKnowledge {
pub files: HashMap<PathBuf, FileInfo>,
pub decisions: Vec<Decision>,
pub patterns: Vec<Pattern>,
pub story_changes: Vec<StoryChanges>,
pub baseline_commit: Option<String>,
}
impl ProjectKnowledge {
pub fn our_files(&self) -> HashSet<&PathBuf> {
let mut files = HashSet::new();
for story in &self.story_changes {
for change in &story.files_created {
files.insert(&change.path);
}
for change in &story.files_modified {
files.insert(&change.path);
}
for path in &story.files_deleted {
files.insert(path);
}
}
files
}
pub fn filter_our_changes(&self, all_changes: &[DiffEntry]) -> Vec<DiffEntry> {
let our_files = self.our_files();
all_changes
.iter()
.filter(|entry| {
if entry.status == crate::git::DiffStatus::Added {
return true;
}
our_files.contains(&entry.path)
})
.cloned()
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileInfo {
pub purpose: String,
pub key_symbols: Vec<String>,
pub touched_by: Vec<String>,
pub line_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Decision {
pub story_id: String,
pub topic: String,
pub choice: String,
pub rationale: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Pattern {
pub story_id: String,
pub description: String,
pub example_file: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoryChanges {
pub story_id: String,
pub files_created: Vec<FileChange>,
pub files_modified: Vec<FileChange>,
pub files_deleted: Vec<PathBuf>,
pub commit_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileChange {
pub path: PathBuf,
pub additions: u32,
pub deletions: u32,
pub purpose: Option<String>,
pub key_symbols: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_project_knowledge_default() {
let knowledge = ProjectKnowledge::default();
assert!(knowledge.files.is_empty());
assert!(knowledge.decisions.is_empty());
assert!(knowledge.patterns.is_empty());
assert!(knowledge.story_changes.is_empty());
assert!(knowledge.baseline_commit.is_none());
}
#[test]
fn test_project_knowledge_debug_impl() {
let knowledge = ProjectKnowledge::default();
let debug_str = format!("{:?}", knowledge);
assert!(debug_str.contains("ProjectKnowledge"));
}
#[test]
fn test_project_knowledge_clone() {
let mut knowledge = ProjectKnowledge::default();
knowledge.baseline_commit = Some("abc123".to_string());
let cloned = knowledge.clone();
assert_eq!(cloned.baseline_commit, Some("abc123".to_string()));
}
#[test]
fn test_project_knowledge_serialization_roundtrip() {
let mut knowledge = ProjectKnowledge::default();
knowledge.baseline_commit = Some("abc123".to_string());
knowledge.decisions.push(Decision {
story_id: "US-001".to_string(),
topic: "Architecture".to_string(),
choice: "Use modules".to_string(),
rationale: "Better organization".to_string(),
});
let json = serde_json::to_string(&knowledge).unwrap();
let deserialized: ProjectKnowledge = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.baseline_commit, Some("abc123".to_string()));
assert_eq!(deserialized.decisions.len(), 1);
assert_eq!(deserialized.decisions[0].story_id, "US-001");
}
#[test]
fn test_project_knowledge_camel_case_serialization() {
let knowledge = ProjectKnowledge {
baseline_commit: Some("abc".to_string()),
story_changes: vec![StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![],
files_modified: vec![],
files_deleted: vec![],
commit_hash: None,
}],
..Default::default()
};
let json = serde_json::to_string(&knowledge).unwrap();
assert!(json.contains("baselineCommit"));
assert!(json.contains("storyChanges"));
assert!(json.contains("storyId"));
assert!(json.contains("filesCreated"));
assert!(json.contains("filesModified"));
assert!(json.contains("filesDeleted"));
assert!(json.contains("commitHash"));
}
#[test]
fn test_file_info_creation() {
let file_info = FileInfo {
purpose: "Main entry point".to_string(),
key_symbols: vec!["main".to_string(), "run".to_string()],
touched_by: vec!["US-001".to_string()],
line_count: 150,
};
assert_eq!(file_info.purpose, "Main entry point");
assert_eq!(file_info.key_symbols.len(), 2);
assert_eq!(file_info.touched_by.len(), 1);
assert_eq!(file_info.line_count, 150);
}
#[test]
fn test_file_info_serialization() {
let file_info = FileInfo {
purpose: "Test file".to_string(),
key_symbols: vec!["test_fn".to_string()],
touched_by: vec!["US-001".to_string()],
line_count: 50,
};
let json = serde_json::to_string(&file_info).unwrap();
assert!(json.contains("keySymbols"));
assert!(json.contains("touchedBy"));
assert!(json.contains("lineCount"));
let deserialized: FileInfo = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.purpose, "Test file");
assert_eq!(deserialized.line_count, 50);
}
#[test]
fn test_decision_creation() {
let decision = Decision {
story_id: "US-001".to_string(),
topic: "Database".to_string(),
choice: "SQLite".to_string(),
rationale: "Simple, embedded, no setup".to_string(),
};
assert_eq!(decision.story_id, "US-001");
assert_eq!(decision.topic, "Database");
assert_eq!(decision.choice, "SQLite");
assert_eq!(decision.rationale, "Simple, embedded, no setup");
}
#[test]
fn test_decision_serialization() {
let decision = Decision {
story_id: "US-002".to_string(),
topic: "Auth".to_string(),
choice: "JWT".to_string(),
rationale: "Stateless".to_string(),
};
let json = serde_json::to_string(&decision).unwrap();
assert!(json.contains("storyId"));
let deserialized: Decision = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.story_id, "US-002");
}
#[test]
fn test_pattern_creation_with_example() {
let pattern = Pattern {
story_id: "US-001".to_string(),
description: "Use Result<T, Error> for all fallible operations".to_string(),
example_file: Some(PathBuf::from("src/runner.rs")),
};
assert_eq!(pattern.story_id, "US-001");
assert!(pattern.example_file.is_some());
}
#[test]
fn test_pattern_creation_without_example() {
let pattern = Pattern {
story_id: "US-001".to_string(),
description: "Use snake_case for function names".to_string(),
example_file: None,
};
assert!(pattern.example_file.is_none());
}
#[test]
fn test_pattern_serialization() {
let pattern = Pattern {
story_id: "US-001".to_string(),
description: "Test pattern".to_string(),
example_file: Some(PathBuf::from("src/lib.rs")),
};
let json = serde_json::to_string(&pattern).unwrap();
assert!(json.contains("storyId"));
assert!(json.contains("exampleFile"));
let deserialized: Pattern = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.example_file, Some(PathBuf::from("src/lib.rs")));
}
#[test]
fn test_story_changes_creation() {
let changes = StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![FileChange {
path: PathBuf::from("src/new.rs"),
additions: 100,
deletions: 0,
purpose: Some("New module".to_string()),
key_symbols: vec!["NewStruct".to_string()],
}],
files_modified: vec![FileChange {
path: PathBuf::from("src/lib.rs"),
additions: 5,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_deleted: vec![PathBuf::from("src/old.rs")],
commit_hash: Some("def456".to_string()),
};
assert_eq!(changes.story_id, "US-001");
assert_eq!(changes.files_created.len(), 1);
assert_eq!(changes.files_modified.len(), 1);
assert_eq!(changes.files_deleted.len(), 1);
assert!(changes.commit_hash.is_some());
}
#[test]
fn test_story_changes_without_commit() {
let changes = StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![],
files_modified: vec![],
files_deleted: vec![],
commit_hash: None,
};
assert!(changes.commit_hash.is_none());
}
#[test]
fn test_story_changes_serialization() {
let changes = StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![],
files_modified: vec![],
files_deleted: vec![],
commit_hash: Some("abc".to_string()),
};
let json = serde_json::to_string(&changes).unwrap();
assert!(json.contains("storyId"));
assert!(json.contains("filesCreated"));
assert!(json.contains("filesModified"));
assert!(json.contains("filesDeleted"));
assert!(json.contains("commitHash"));
}
#[test]
fn test_file_change_creation() {
let change = FileChange {
path: PathBuf::from("src/test.rs"),
additions: 50,
deletions: 10,
purpose: Some("Test utilities".to_string()),
key_symbols: vec!["test_helper".to_string(), "setup".to_string()],
};
assert_eq!(change.path, PathBuf::from("src/test.rs"));
assert_eq!(change.additions, 50);
assert_eq!(change.deletions, 10);
assert!(change.purpose.is_some());
assert_eq!(change.key_symbols.len(), 2);
}
#[test]
fn test_file_change_minimal() {
let change = FileChange {
path: PathBuf::from("src/lib.rs"),
additions: 1,
deletions: 0,
purpose: None,
key_symbols: vec![],
};
assert!(change.purpose.is_none());
assert!(change.key_symbols.is_empty());
}
#[test]
fn test_file_change_serialization() {
let change = FileChange {
path: PathBuf::from("src/test.rs"),
additions: 10,
deletions: 5,
purpose: Some("Test".to_string()),
key_symbols: vec!["sym".to_string()],
};
let json = serde_json::to_string(&change).unwrap();
assert!(json.contains("keySymbols"));
let deserialized: FileChange = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.additions, 10);
assert_eq!(deserialized.deletions, 5);
}
#[test]
fn test_project_knowledge_with_files() {
let mut knowledge = ProjectKnowledge::default();
knowledge.files.insert(
PathBuf::from("src/main.rs"),
FileInfo {
purpose: "Application entry point".to_string(),
key_symbols: vec!["main".to_string()],
touched_by: vec!["US-001".to_string()],
line_count: 100,
},
);
assert_eq!(knowledge.files.len(), 1);
let file_info = knowledge.files.get(&PathBuf::from("src/main.rs")).unwrap();
assert_eq!(file_info.purpose, "Application entry point");
}
#[test]
fn test_full_knowledge_serialization_roundtrip() {
let mut knowledge = ProjectKnowledge::default();
knowledge.baseline_commit = Some("baseline123".to_string());
knowledge.files.insert(
PathBuf::from("src/lib.rs"),
FileInfo {
purpose: "Library root".to_string(),
key_symbols: vec!["mod".to_string()],
touched_by: vec!["US-001".to_string(), "US-002".to_string()],
line_count: 50,
},
);
knowledge.decisions.push(Decision {
story_id: "US-001".to_string(),
topic: "Error handling".to_string(),
choice: "thiserror crate".to_string(),
rationale: "Clean error types".to_string(),
});
knowledge.patterns.push(Pattern {
story_id: "US-001".to_string(),
description: "Use ? operator for error propagation".to_string(),
example_file: Some(PathBuf::from("src/runner.rs")),
});
knowledge.story_changes.push(StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![FileChange {
path: PathBuf::from("src/knowledge.rs"),
additions: 200,
deletions: 0,
purpose: Some("Knowledge tracking".to_string()),
key_symbols: vec!["ProjectKnowledge".to_string()],
}],
files_modified: vec![FileChange {
path: PathBuf::from("src/lib.rs"),
additions: 1,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_deleted: vec![],
commit_hash: Some("commit123".to_string()),
});
let json = serde_json::to_string_pretty(&knowledge).unwrap();
let deserialized: ProjectKnowledge = serde_json::from_str(&json).unwrap();
assert_eq!(
deserialized.baseline_commit,
Some("baseline123".to_string())
);
assert_eq!(deserialized.files.len(), 1);
assert_eq!(deserialized.decisions.len(), 1);
assert_eq!(deserialized.patterns.len(), 1);
assert_eq!(deserialized.story_changes.len(), 1);
let file_info = deserialized
.files
.get(&PathBuf::from("src/lib.rs"))
.unwrap();
assert_eq!(file_info.touched_by.len(), 2);
let story_changes = &deserialized.story_changes[0];
assert_eq!(story_changes.files_created.len(), 1);
assert_eq!(story_changes.files_created[0].additions, 200);
}
#[test]
fn test_our_files_empty_knowledge() {
let knowledge = ProjectKnowledge::default();
let files = knowledge.our_files();
assert!(files.is_empty());
}
#[test]
fn test_our_files_with_created_files() {
let mut knowledge = ProjectKnowledge::default();
knowledge.story_changes.push(StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![FileChange {
path: PathBuf::from("src/new.rs"),
additions: 100,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_modified: vec![],
files_deleted: vec![],
commit_hash: None,
});
let files = knowledge.our_files();
assert_eq!(files.len(), 1);
assert!(files.contains(&PathBuf::from("src/new.rs")));
}
#[test]
fn test_our_files_with_modified_files() {
let mut knowledge = ProjectKnowledge::default();
knowledge.story_changes.push(StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![],
files_modified: vec![FileChange {
path: PathBuf::from("src/lib.rs"),
additions: 10,
deletions: 5,
purpose: None,
key_symbols: vec![],
}],
files_deleted: vec![],
commit_hash: None,
});
let files = knowledge.our_files();
assert_eq!(files.len(), 1);
assert!(files.contains(&PathBuf::from("src/lib.rs")));
}
#[test]
fn test_our_files_with_deleted_files() {
let mut knowledge = ProjectKnowledge::default();
knowledge.story_changes.push(StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![],
files_modified: vec![],
files_deleted: vec![PathBuf::from("src/old.rs")],
commit_hash: None,
});
let files = knowledge.our_files();
assert_eq!(files.len(), 1);
assert!(files.contains(&PathBuf::from("src/old.rs")));
}
#[test]
fn test_our_files_multiple_stories() {
let mut knowledge = ProjectKnowledge::default();
knowledge.story_changes.push(StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![FileChange {
path: PathBuf::from("src/a.rs"),
additions: 50,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_modified: vec![FileChange {
path: PathBuf::from("src/lib.rs"),
additions: 1,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_deleted: vec![],
commit_hash: None,
});
knowledge.story_changes.push(StoryChanges {
story_id: "US-002".to_string(),
files_created: vec![FileChange {
path: PathBuf::from("src/b.rs"),
additions: 30,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_modified: vec![FileChange {
path: PathBuf::from("src/lib.rs"),
additions: 2,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_deleted: vec![PathBuf::from("src/old.rs")],
commit_hash: None,
});
let files = knowledge.our_files();
assert_eq!(files.len(), 4);
assert!(files.contains(&PathBuf::from("src/a.rs")));
assert!(files.contains(&PathBuf::from("src/b.rs")));
assert!(files.contains(&PathBuf::from("src/lib.rs")));
assert!(files.contains(&PathBuf::from("src/old.rs")));
}
#[test]
fn test_our_files_deduplicates() {
let mut knowledge = ProjectKnowledge::default();
knowledge.story_changes.push(StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![],
files_modified: vec![FileChange {
path: PathBuf::from("src/lib.rs"),
additions: 10,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_deleted: vec![],
commit_hash: None,
});
knowledge.story_changes.push(StoryChanges {
story_id: "US-002".to_string(),
files_created: vec![],
files_modified: vec![FileChange {
path: PathBuf::from("src/lib.rs"),
additions: 5,
deletions: 2,
purpose: None,
key_symbols: vec![],
}],
files_deleted: vec![],
commit_hash: None,
});
let files = knowledge.our_files();
assert_eq!(files.len(), 1);
assert!(files.contains(&PathBuf::from("src/lib.rs")));
}
#[test]
fn test_filter_our_changes_empty_knowledge() {
use crate::git::{DiffEntry, DiffStatus};
let knowledge = ProjectKnowledge::default();
let all_changes = vec![
DiffEntry {
path: PathBuf::from("src/external.rs"),
additions: 10,
deletions: 0,
status: DiffStatus::Modified,
},
DiffEntry {
path: PathBuf::from("src/new.rs"),
additions: 50,
deletions: 0,
status: DiffStatus::Added,
},
];
let filtered = knowledge.filter_our_changes(&all_changes);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path, PathBuf::from("src/new.rs"));
}
#[test]
fn test_filter_our_changes_includes_our_modified_files() {
use crate::git::{DiffEntry, DiffStatus};
let mut knowledge = ProjectKnowledge::default();
knowledge.story_changes.push(StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![FileChange {
path: PathBuf::from("src/our_file.rs"),
additions: 100,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_modified: vec![],
files_deleted: vec![],
commit_hash: None,
});
let all_changes = vec![
DiffEntry {
path: PathBuf::from("src/our_file.rs"),
additions: 10,
deletions: 5,
status: DiffStatus::Modified,
},
DiffEntry {
path: PathBuf::from("src/external.rs"),
additions: 20,
deletions: 0,
status: DiffStatus::Modified,
},
];
let filtered = knowledge.filter_our_changes(&all_changes);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path, PathBuf::from("src/our_file.rs"));
}
#[test]
fn test_filter_our_changes_includes_new_files() {
use crate::git::{DiffEntry, DiffStatus};
let mut knowledge = ProjectKnowledge::default();
knowledge.story_changes.push(StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![FileChange {
path: PathBuf::from("src/old_new.rs"),
additions: 50,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_modified: vec![],
files_deleted: vec![],
commit_hash: None,
});
let all_changes = vec![
DiffEntry {
path: PathBuf::from("src/brand_new.rs"),
additions: 100,
deletions: 0,
status: DiffStatus::Added,
},
DiffEntry {
path: PathBuf::from("src/external.rs"),
additions: 20,
deletions: 10,
status: DiffStatus::Modified,
},
];
let filtered = knowledge.filter_our_changes(&all_changes);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path, PathBuf::from("src/brand_new.rs"));
}
#[test]
fn test_filter_our_changes_excludes_external_modifications() {
use crate::git::{DiffEntry, DiffStatus};
let mut knowledge = ProjectKnowledge::default();
knowledge.story_changes.push(StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![FileChange {
path: PathBuf::from("src/our.rs"),
additions: 50,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_modified: vec![],
files_deleted: vec![],
commit_hash: None,
});
let all_changes = vec![
DiffEntry {
path: PathBuf::from("package-lock.json"),
additions: 1000,
deletions: 500,
status: DiffStatus::Modified,
},
DiffEntry {
path: PathBuf::from(".env"),
additions: 1,
deletions: 0,
status: DiffStatus::Modified,
},
];
let filtered = knowledge.filter_our_changes(&all_changes);
assert!(filtered.is_empty());
}
#[test]
fn test_filter_our_changes_complex_scenario() {
use crate::git::{DiffEntry, DiffStatus};
let mut knowledge = ProjectKnowledge::default();
knowledge.story_changes.push(StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![FileChange {
path: PathBuf::from("src/a.rs"),
additions: 50,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_modified: vec![FileChange {
path: PathBuf::from("src/lib.rs"),
additions: 1,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_deleted: vec![],
commit_hash: None,
});
let all_changes = vec![
DiffEntry {
path: PathBuf::from("src/a.rs"),
additions: 10,
deletions: 5,
status: DiffStatus::Modified,
},
DiffEntry {
path: PathBuf::from("src/lib.rs"),
additions: 3,
deletions: 1,
status: DiffStatus::Modified,
},
DiffEntry {
path: PathBuf::from("src/b.rs"),
additions: 100,
deletions: 0,
status: DiffStatus::Added,
},
DiffEntry {
path: PathBuf::from("Cargo.lock"),
additions: 500,
deletions: 100,
status: DiffStatus::Modified,
},
DiffEntry {
path: PathBuf::from(".github/workflows/ci.yml"),
additions: 50,
deletions: 0,
status: DiffStatus::Added,
},
];
let filtered = knowledge.filter_our_changes(&all_changes);
assert_eq!(filtered.len(), 4);
let paths: Vec<_> = filtered.iter().map(|e| e.path.clone()).collect();
assert!(paths.contains(&PathBuf::from("src/a.rs")));
assert!(paths.contains(&PathBuf::from("src/lib.rs")));
assert!(paths.contains(&PathBuf::from("src/b.rs")));
assert!(paths.contains(&PathBuf::from(".github/workflows/ci.yml")));
assert!(!paths.contains(&PathBuf::from("Cargo.lock")));
}
#[test]
fn test_filter_our_changes_with_deleted_file() {
use crate::git::{DiffEntry, DiffStatus};
let mut knowledge = ProjectKnowledge::default();
knowledge.story_changes.push(StoryChanges {
story_id: "US-001".to_string(),
files_created: vec![],
files_modified: vec![FileChange {
path: PathBuf::from("src/to_delete.rs"),
additions: 5,
deletions: 0,
purpose: None,
key_symbols: vec![],
}],
files_deleted: vec![],
commit_hash: None,
});
let all_changes = vec![DiffEntry {
path: PathBuf::from("src/to_delete.rs"),
additions: 0,
deletions: 50,
status: DiffStatus::Deleted,
}];
let filtered = knowledge.filter_our_changes(&all_changes);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path, PathBuf::from("src/to_delete.rs"));
assert_eq!(filtered[0].status, DiffStatus::Deleted);
}
#[test]
fn test_filter_our_changes_empty_input() {
let knowledge = ProjectKnowledge::default();
let filtered = knowledge.filter_our_changes(&[]);
assert!(filtered.is_empty());
}
}