blame_rs/blame.rs
1use crate::types::{
2 BlameError, BlameLine, BlameOptions, BlameResult, BlameRevision, DiffAlgorithm,
3};
4use similar::{Algorithm, ChangeTag, TextDiff};
5use std::rc::Rc;
6
7#[derive(Debug)]
8struct LineOrigin<'a, T> {
9 content: &'a str,
10 metadata: Rc<T>,
11}
12
13impl<'a, T> Clone for LineOrigin<'a, T> {
14 fn clone(&self) -> Self {
15 Self {
16 content: self.content,
17 metadata: Rc::clone(&self.metadata),
18 }
19 }
20}
21
22/// Performs a blame operation on a sequence of revisions to determine the origin of each line.
23///
24/// This function takes a slice of `BlameRevision` objects ordered chronologically (oldest to newest)
25/// and computes which revision each line in the final version originated from.
26///
27/// # Arguments
28///
29/// * `revisions` - A slice of revisions ordered chronologically (oldest first, newest last)
30///
31/// # Returns
32///
33/// Returns a `BlameResult` containing each line of the final revision along with metadata
34/// about which revision introduced that line.
35///
36/// # Errors
37///
38/// Returns `BlameError::EmptyRevisions` if the revisions slice is empty.
39///
40/// # Example
41///
42/// ```ignore
43/// use blame_rs::{blame, BlameRevision};
44/// use std::rc::Rc;
45///
46/// #[derive(Debug)]
47/// struct CommitInfo {
48/// hash: String,
49/// author: String,
50/// }
51///
52/// let revisions = vec![
53/// BlameRevision {
54/// content: "line 1\nline 2",
55/// metadata: Rc::new(CommitInfo { hash: "abc123".into(), author: "Alice".into() }),
56/// },
57/// BlameRevision {
58/// content: "line 1\nline 2\nline 3",
59/// metadata: Rc::new(CommitInfo { hash: "def456".into(), author: "Bob".into() }),
60/// },
61/// ];
62///
63/// let result = blame(&revisions)?;
64/// ```
65pub fn blame<'a, T>(revisions: &'a [BlameRevision<'a, T>]) -> Result<BlameResult<'a, T>, BlameError> {
66 blame_with_options(revisions, BlameOptions::default())
67}
68
69/// Performs a blame operation with custom options.
70///
71/// # Arguments
72///
73/// * `revisions` - A slice of revisions ordered chronologically (oldest first, newest last)
74/// * `options` - Configuration options for the blame operation
75///
76/// # Returns
77///
78/// Returns a `BlameResult` containing each line of the final revision along with metadata
79/// about which revision introduced that line.
80///
81/// # Errors
82///
83/// Returns `BlameError::EmptyRevisions` if the revisions slice is empty.
84///
85/// # Example
86///
87/// ```ignore
88/// use blame_rs::{blame_with_options, BlameOptions, BlameRevision, DiffAlgorithm};
89///
90/// let options = BlameOptions {
91/// algorithm: DiffAlgorithm::Patience,
92/// };
93///
94/// let result = blame_with_options(&revisions, options)?;
95/// ```
96pub fn blame_with_options<'a, T>(
97 revisions: &'a [BlameRevision<'a, T>],
98 options: BlameOptions,
99) -> Result<BlameResult<'a, T>, BlameError> {
100 if revisions.is_empty() {
101 return Err(BlameError::EmptyRevisions);
102 }
103
104 let similar_algorithm = match options.algorithm {
105 DiffAlgorithm::Myers => Algorithm::Myers,
106 DiffAlgorithm::Patience => Algorithm::Patience,
107 };
108
109 let first_revision = &revisions[0];
110
111 let init_diff = TextDiff::configure()
112 .algorithm(similar_algorithm)
113 .diff_lines("", first_revision.content);
114
115 // Pre-allocate capacity based on estimated line count
116 let estimated_lines = first_revision.content.lines().count();
117 let mut line_origins: Vec<LineOrigin<'a, T>> = Vec::with_capacity(estimated_lines);
118
119 // Create shared reference to first revision's metadata
120 let first_metadata = Rc::clone(&first_revision.metadata);
121
122 for change in init_diff.iter_all_changes() {
123 if change.tag() == ChangeTag::Insert {
124 line_origins.push(LineOrigin {
125 content: change.value(),
126 metadata: Rc::clone(&first_metadata),
127 });
128 }
129 }
130
131 // Forward iteration: track each line's origin through revisions
132 for i in 0..revisions.len() - 1 {
133 let old_content = revisions[i].content;
134 let new_content = revisions[i + 1].content;
135
136 // Create shared reference to this revision's metadata
137 let shared_metadata = Rc::clone(&revisions[i + 1].metadata);
138
139 let diff = TextDiff::configure()
140 .algorithm(similar_algorithm)
141 .diff_lines(old_content, new_content);
142
143 // Pre-allocate based on new content's line count
144 let estimated_new_lines = new_content.lines().count();
145 let mut new_line_origins: Vec<LineOrigin<'a, T>> = Vec::with_capacity(estimated_new_lines);
146
147 for change in diff.iter_all_changes() {
148 match change.tag() {
149 ChangeTag::Equal => {
150 let old_line_num = change
151 .old_index()
152 .expect("Equal change must have old_index");
153 let origin = line_origins
154 .get(old_line_num)
155 .expect("old_index should be within line_origins bounds");
156 new_line_origins.push(origin.clone());
157 }
158 ChangeTag::Insert => {
159 new_line_origins.push(LineOrigin {
160 content: change.value(),
161 metadata: Rc::clone(&shared_metadata),
162 });
163 }
164 ChangeTag::Delete => {}
165 }
166 }
167
168 line_origins = new_line_origins;
169 }
170
171 let blame_lines: Vec<BlameLine<'a, T>> = line_origins
172 .into_iter()
173 .enumerate()
174 .map(|(idx, origin)| BlameLine {
175 line_number: idx,
176 content: origin.content,
177 revision_metadata: origin.metadata,
178 })
179 .collect();
180
181 Ok(BlameResult::new(blame_lines))
182}