1use chrono::Local;
11use docgen_core::markdown::render_markdown;
12
13use crate::error::DiffError;
14use crate::types::{
15 DocDiffBlockKind, DocDiffFile, DocDiffLineKind, DocDiffReport, DocDiffTimelinePoint,
16 DocDiffTimelinePointKind,
17};
18use crate::{block_diff, file_tree, git_refs, history, line_diff};
19
20pub fn build_doc_diff_report(
24 repo: &git2::Repository,
25 doc_rel_path: &str,
26 limit: usize,
27) -> Result<Option<DocDiffReport>, DiffError> {
28 build_doc_diff_report_inner(repo, doc_rel_path, limit, false)
29}
30
31pub fn build_doc_diff_report_with_blocks(
35 repo: &git2::Repository,
36 doc_rel_path: &str,
37 limit: usize,
38) -> Result<Option<DocDiffReport>, DiffError> {
39 build_doc_diff_report_inner(repo, doc_rel_path, limit, true)
40}
41
42fn build_doc_diff_report_inner(
43 repo: &git2::Repository,
44 doc_rel_path: &str,
45 limit: usize,
46 with_blocks: bool,
47) -> Result<Option<DocDiffReport>, DiffError> {
48 let revs = history::doc_revisions(repo, doc_rel_path, limit)?;
49 if revs.is_empty() {
50 return Ok(None);
51 }
52
53 let mut timeline: Vec<DocDiffTimelinePoint> = Vec::with_capacity(revs.len());
54
55 for rev in revs {
56 let hunks = line_diff::build_line_hunks_default(&rev.old_text, &rev.new_text);
57
58 let mut blocks = block_diff::build_block_diff(&rev.old_text, &rev.new_text);
63 let all_context = blocks.iter().all(|b| b.kind == DocDiffBlockKind::Context);
64 let blocks = if with_blocks {
65 for block in &mut blocks {
66 block.html = render_markdown(&block.raw);
67 }
68 Some(blocks)
69 } else {
70 None
71 };
72
73 let files: Vec<DocDiffFile> = if hunks.is_empty() && all_context {
76 vec![]
77 } else {
78 let added_lines = count_lines(&hunks, DocDiffLineKind::Added);
79 let removed_lines = count_lines(&hunks, DocDiffLineKind::Removed);
80 vec![DocDiffFile {
81 path: rev.path.clone(),
82 old_path: rev.old_path.clone(),
83 status: rev.status,
84 added_lines,
85 removed_lines,
86 hunks,
87 blocks,
88 }]
89 };
90
91 let file_tree = file_tree::build_file_tree(&files);
92 let total_added_lines = files.iter().map(|f| f.added_lines).sum();
93 let total_removed_lines = files.iter().map(|f| f.removed_lines).sum();
94 let base_ref = git_refs::base_ref_for_parents(&rev.meta.parents);
95 let head_ref = rev.meta.hash.clone();
96
97 timeline.push(DocDiffTimelinePoint {
98 id: rev.meta.hash.clone(),
99 kind: DocDiffTimelinePointKind::Commit,
100 hash: Some(rev.meta.hash.clone()),
101 short_hash: rev.meta.short_hash.clone(),
102 subject: rev.meta.subject.clone(),
103 author: rev.meta.author.clone(),
104 date: rev.meta.date.clone(),
105 base_ref,
106 head_ref,
107 files,
108 file_tree,
109 total_added_lines,
110 total_removed_lines,
111 warnings: vec![],
112 });
113 }
114
115 Ok(Some(report_from_timeline(timeline)))
116}
117
118pub fn build_global_doc_diff_report(
124 repo: &git2::Repository,
125 docs_prefix: &str,
126 limit: usize,
127 with_blocks: bool,
128) -> Result<Option<DocDiffReport>, DiffError> {
129 let revs = history::global_doc_revisions(repo, docs_prefix, limit)?;
130 if revs.is_empty() {
131 return Ok(None);
132 }
133
134 let mut timeline: Vec<DocDiffTimelinePoint> = Vec::with_capacity(revs.len());
135
136 for rev in revs {
137 let mut files: Vec<DocDiffFile> = Vec::new();
138 for change in &rev.files {
139 let hunks = line_diff::build_line_hunks_default(&change.old_text, &change.new_text);
140 let mut blocks = block_diff::build_block_diff(&change.old_text, &change.new_text);
141 let all_context = blocks.iter().all(|b| b.kind == DocDiffBlockKind::Context);
142
143 if hunks.is_empty() && all_context {
146 continue;
147 }
148
149 let blocks = if with_blocks {
150 for block in &mut blocks {
151 block.html = render_markdown(&block.raw);
152 }
153 Some(blocks)
154 } else {
155 None
156 };
157
158 files.push(DocDiffFile {
159 path: change.path.clone(),
160 old_path: change.old_path.clone(),
161 status: change.status,
162 added_lines: count_lines(&hunks, DocDiffLineKind::Added),
163 removed_lines: count_lines(&hunks, DocDiffLineKind::Removed),
164 hunks,
165 blocks,
166 });
167 }
168
169 if files.is_empty() {
171 continue;
172 }
173
174 let file_tree = file_tree::build_file_tree(&files);
175 let total_added_lines = files.iter().map(|f| f.added_lines).sum();
176 let total_removed_lines = files.iter().map(|f| f.removed_lines).sum();
177 let base_ref = git_refs::base_ref_for_parents(&rev.meta.parents);
178 let head_ref = rev.meta.hash.clone();
179
180 timeline.push(DocDiffTimelinePoint {
181 id: rev.meta.hash.clone(),
182 kind: DocDiffTimelinePointKind::Commit,
183 hash: Some(rev.meta.hash.clone()),
184 short_hash: rev.meta.short_hash.clone(),
185 subject: rev.meta.subject.clone(),
186 author: rev.meta.author.clone(),
187 date: rev.meta.date.clone(),
188 base_ref,
189 head_ref,
190 files,
191 file_tree,
192 total_added_lines,
193 total_removed_lines,
194 warnings: vec![],
195 });
196 }
197
198 if timeline.is_empty() {
199 return Ok(None);
200 }
201
202 Ok(Some(report_from_timeline(timeline)))
203}
204
205fn count_lines(hunks: &[crate::types::DocDiffHunk], kind: DocDiffLineKind) -> u32 {
206 hunks
207 .iter()
208 .flat_map(|hunk| hunk.lines.iter())
209 .filter(|line| line.kind == kind)
210 .count() as u32
211}
212
213fn report_from_timeline(timeline: Vec<DocDiffTimelinePoint>) -> DocDiffReport {
214 let selected_point = timeline.first();
215 let selected_file = selected_point.and_then(|p| p.files.first());
216
217 let base_ref = selected_point
218 .map(|p| p.base_ref.clone())
219 .unwrap_or_else(|| git_refs::EMPTY_TREE_REF.to_string());
220 let head_ref = selected_point
221 .map(|p| p.head_ref.clone())
222 .unwrap_or_else(|| "HEAD".to_string());
223 let selected_point_id = selected_point.map(|p| p.id.clone());
224 let selected_file_path = selected_file.map(|f| f.path.clone());
225 let files = selected_point.map(|p| p.files.clone()).unwrap_or_default();
226 let total_added_lines = selected_point.map(|p| p.total_added_lines).unwrap_or(0);
227 let total_removed_lines = selected_point.map(|p| p.total_removed_lines).unwrap_or(0);
228
229 DocDiffReport {
230 mode: "build-history".into(),
231 base_ref,
232 head_ref,
233 generated_at: Local::now().to_rfc3339(),
234 timeline,
235 selected_point_id,
236 selected_file_path,
237 files,
238 total_added_lines,
239 total_removed_lines,
240 warnings: vec![],
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::testutil::TempRepo;
248 use crate::types::DocDiffFileStatus;
249
250 #[test]
251 fn report_builds_timeline_for_a_doc_with_hunks_and_blocks() {
252 let r = TempRepo::init();
253 r.commit_file("docs/a.md", "# A\n\nfirst paragraph.\n", "add a");
254 r.commit_file("docs/a.md", "# A\n\nsecond paragraph.\n", "edit a");
255
256 let report = build_doc_diff_report_with_blocks(&r.repo, "docs/a.md", 50)
257 .unwrap()
258 .unwrap();
259 assert_eq!(report.mode, "build-history");
260 assert_eq!(report.timeline.len(), 2);
261
262 let head = &report.timeline[0];
263 assert_eq!(head.subject, "edit a");
264 assert_eq!(head.kind, DocDiffTimelinePointKind::Commit);
265
266 let file = &head.files[0];
267 assert_eq!(file.path, "docs/a.md");
268 assert!(!file.hunks.is_empty());
269
270 let blocks = file.blocks.as_ref().unwrap();
271 assert!(blocks
272 .iter()
273 .any(|b| b.kind == DocDiffBlockKind::Removed && b.raw == "first paragraph."));
274 assert!(blocks
275 .iter()
276 .any(|b| b.kind == DocDiffBlockKind::Added && b.raw == "second paragraph."));
277 assert!(blocks.iter().any(|b| b.html.contains("<p>")));
279
280 assert!(head.total_added_lines >= 1 && head.total_removed_lines >= 1);
281 assert!(!head.file_tree.is_empty());
282
283 assert_eq!(report.timeline[1].files[0].status, DocDiffFileStatus::Added);
285
286 assert_eq!(report.selected_point_id.as_deref(), Some(head.id.as_str()));
288 assert_eq!(report.selected_file_path.as_deref(), Some("docs/a.md"));
289 }
290
291 #[test]
292 fn report_without_blocks_skips_block_rendering() {
293 let r = TempRepo::init();
294 r.commit_file("docs/a.md", "# A\n\nfirst paragraph.\n", "add a");
295 r.commit_file("docs/a.md", "# A\n\nsecond paragraph.\n", "edit a");
296
297 let report = build_doc_diff_report(&r.repo, "docs/a.md", 50)
298 .unwrap()
299 .unwrap();
300 let head = &report.timeline[0];
302 let file = &head.files[0];
303 assert!(!file.hunks.is_empty());
304 assert!(head.total_added_lines >= 1 && head.total_removed_lines >= 1);
305 assert!(file.blocks.is_none());
307 }
308
309 #[test]
310 fn global_report_groups_all_changed_docs_per_commit() {
311 let r = TempRepo::init();
312 r.commit_file("docs/a.md", "# A\n\nfirst.\n", "init");
314 std::fs::write(r.dir.join("docs/b.md"), "# B\n\nbee.\n").unwrap();
315 r.commit_all("add b");
316 std::fs::write(r.dir.join("docs/a.md"), "# A\n\nsecond.\n").unwrap();
318 std::fs::create_dir_all(r.dir.join("docs/sub")).unwrap();
319 std::fs::write(r.dir.join("docs/sub/c.md"), "# C\n\ncee.\n").unwrap();
320 r.commit_all("edit a + add c");
321
322 let report = build_global_doc_diff_report(&r.repo, "docs", 50, true)
323 .unwrap()
324 .unwrap();
325 assert_eq!(report.mode, "build-history");
326
327 let head = &report.timeline[0];
329 let paths: Vec<&str> = head.files.iter().map(|f| f.path.as_str()).collect();
330 assert!(paths.contains(&"docs/a.md"));
331 assert!(paths.contains(&"docs/sub/c.md"));
332 assert!(head.files.iter().any(|f| f
334 .blocks
335 .as_ref()
336 .is_some_and(|b| b.iter().any(|x| x.html.contains("<p>")))));
337 assert!(!head.file_tree.is_empty());
339
340 assert_eq!(report.selected_point_id.as_deref(), Some(head.id.as_str()));
342 }
343
344 #[test]
345 fn global_report_summary_strips_blocks() {
346 let r = TempRepo::init();
347 r.commit_file("docs/a.md", "# A\n\none.\n", "init");
348 r.commit_file("docs/a.md", "# A\n\ntwo.\n", "edit");
349 let report = build_global_doc_diff_report(&r.repo, "docs", 50, false)
350 .unwrap()
351 .unwrap();
352 assert!(report.timeline[0].files[0].blocks.is_none());
353 assert!(!report.timeline[0].files[0].hunks.is_empty());
354 }
355
356 #[test]
357 fn global_report_is_none_without_docs_commits() {
358 let r = TempRepo::init();
359 r.commit_file("notes/a.md", "x\n", "non-docs");
360 assert!(build_global_doc_diff_report(&r.repo, "docs", 50, true)
361 .unwrap()
362 .is_none());
363 }
364
365 #[test]
366 fn report_is_none_when_doc_has_no_history() {
367 let r = TempRepo::init();
368 r.commit_file("docs/a.md", "x\n", "a");
369 assert!(build_doc_diff_report(&r.repo, "docs/ghost.md", 50)
370 .unwrap()
371 .is_none());
372 }
373}