Skip to main content

agentic_codebase/temporal/
history.rs

1//! Change history tracking via git.
2//!
3//! Provides types and data structures for tracking file-level change history.
4//! The [`ChangeHistory`] struct stores changes indexed by file path, commit,
5//! and chronological order for efficient querying.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12/// The type of change recorded for a file.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub enum ChangeType {
15    /// File was added for the first time.
16    Add,
17    /// File was modified in place.
18    Modify,
19    /// File was deleted.
20    Delete,
21    /// File was renamed (old path stored in `old_path` field of [`FileChange`]).
22    Rename,
23}
24
25impl std::fmt::Display for ChangeType {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            Self::Add => write!(f, "add"),
29            Self::Modify => write!(f, "modify"),
30            Self::Delete => write!(f, "delete"),
31            Self::Rename => write!(f, "rename"),
32        }
33    }
34}
35
36/// A single recorded change to a file.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct FileChange {
39    /// The file path affected (relative to repo root).
40    pub path: PathBuf,
41    /// The type of change.
42    pub change_type: ChangeType,
43    /// The commit hash (abbreviated or full).
44    pub commit_id: String,
45    /// Unix timestamp (seconds) of the commit.
46    pub timestamp: u64,
47    /// Author name or email.
48    pub author: String,
49    /// Whether the commit message indicates a bug fix.
50    pub is_bugfix: bool,
51    /// Number of lines added in this change.
52    pub lines_added: u32,
53    /// Number of lines deleted in this change.
54    pub lines_deleted: u32,
55    /// Previous path, if this was a rename.
56    pub old_path: Option<PathBuf>,
57}
58
59/// Options for building or querying change history.
60#[derive(Debug, Clone)]
61pub struct HistoryOptions {
62    /// Maximum number of commits to scan.
63    pub max_commits: usize,
64    /// Only include changes after this timestamp (0 = no limit).
65    pub since_timestamp: u64,
66    /// Only include changes before this timestamp (0 = no limit).
67    pub until_timestamp: u64,
68    /// Only include changes to these paths (empty = all).
69    pub path_filter: Vec<PathBuf>,
70}
71
72impl Default for HistoryOptions {
73    fn default() -> Self {
74        Self {
75            max_commits: 10000,
76            since_timestamp: 0,
77            until_timestamp: 0,
78            path_filter: Vec::new(),
79        }
80    }
81}
82
83/// Aggregated change history for a codebase.
84///
85/// Stores changes indexed by file path and commit for efficient lookup.
86/// Does not require git integration at runtime; data can be pre-populated
87/// from any source (git, manual, tests).
88#[derive(Debug, Clone, Default)]
89pub struct ChangeHistory {
90    /// Changes indexed by file path.
91    by_path: HashMap<PathBuf, Vec<FileChange>>,
92    /// All changes in chronological order.
93    chronological: Vec<FileChange>,
94    /// Changes indexed by commit ID.
95    commits: HashMap<String, Vec<FileChange>>,
96}
97
98impl ChangeHistory {
99    /// Create a new empty change history.
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Add a change to the history.
105    ///
106    /// The change is indexed by its path and commit ID, and appended
107    /// to the chronological list.
108    pub fn add_change(&mut self, change: FileChange) {
109        self.by_path
110            .entry(change.path.clone())
111            .or_default()
112            .push(change.clone());
113        self.commits
114            .entry(change.commit_id.clone())
115            .or_default()
116            .push(change.clone());
117        self.chronological.push(change);
118    }
119
120    /// Get all changes for a given file path.
121    pub fn changes_for_path(&self, path: &Path) -> &[FileChange] {
122        self.by_path.get(path).map(|v| v.as_slice()).unwrap_or(&[])
123    }
124
125    /// Get all files changed in a given commit.
126    pub fn files_in_commit(&self, commit_id: &str) -> &[FileChange] {
127        self.commits
128            .get(commit_id)
129            .map(|v| v.as_slice())
130            .unwrap_or(&[])
131    }
132
133    /// Get the total number of changes recorded for a path.
134    pub fn change_count(&self, path: &Path) -> usize {
135        self.by_path.get(path).map(|v| v.len()).unwrap_or(0)
136    }
137
138    /// Get the number of bugfix changes for a path.
139    pub fn bugfix_count(&self, path: &Path) -> usize {
140        self.by_path
141            .get(path)
142            .map(|v| v.iter().filter(|c| c.is_bugfix).count())
143            .unwrap_or(0)
144    }
145
146    /// Get all unique commit IDs.
147    pub fn all_commits(&self) -> Vec<&str> {
148        self.commits.keys().map(|s| s.as_str()).collect()
149    }
150
151    /// Get all changes in chronological order.
152    pub fn chronological(&self) -> &[FileChange] {
153        &self.chronological
154    }
155
156    /// Get all tracked file paths.
157    pub fn all_paths(&self) -> Vec<&Path> {
158        self.by_path.keys().map(|p| p.as_path()).collect()
159    }
160
161    /// Get unique authors for a path.
162    pub fn authors_for_path(&self, path: &Path) -> Vec<String> {
163        let mut authors: Vec<String> = self
164            .by_path
165            .get(path)
166            .map(|v| v.iter().map(|c| c.author.clone()).collect())
167            .unwrap_or_default();
168        authors.sort();
169        authors.dedup();
170        authors
171    }
172
173    /// Get total churn (lines added + deleted) for a path.
174    pub fn total_churn(&self, path: &Path) -> u64 {
175        self.by_path
176            .get(path)
177            .map(|v| {
178                v.iter()
179                    .map(|c| c.lines_added as u64 + c.lines_deleted as u64)
180                    .sum()
181            })
182            .unwrap_or(0)
183    }
184
185    /// Get the total number of changes across all files.
186    pub fn total_changes(&self) -> usize {
187        self.chronological.len()
188    }
189
190    /// Get the total number of unique commits.
191    pub fn total_commits(&self) -> usize {
192        self.commits.len()
193    }
194
195    /// Get the most recent change timestamp for a path, or 0 if none.
196    pub fn latest_timestamp(&self, path: &Path) -> u64 {
197        self.by_path
198            .get(path)
199            .and_then(|v| v.iter().map(|c| c.timestamp).max())
200            .unwrap_or(0)
201    }
202
203    /// Get the oldest change timestamp for a path, or 0 if none.
204    pub fn oldest_timestamp(&self, path: &Path) -> u64 {
205        self.by_path
206            .get(path)
207            .and_then(|v| v.iter().map(|c| c.timestamp).min())
208            .unwrap_or(0)
209    }
210}