Skip to main content

docgen_diff/
report.rs

1//! Build-history orchestrator — the Rust analogue of `git-diff.server.ts`'s
2//! `loadDocDiffTimelineReport` + `buildTimelinePoint` + `reportFromTimeline`,
3//! restricted to one doc and build-history mode.
4//!
5//! Ties the `history` git walk (Cluster A) to the pure diff/grouping layer
6//! (Cluster B): each revision becomes a `DocDiffTimelinePoint` carrying the
7//! doc's line hunks and rendered block diff; the report selects the newest
8//! point as its head.
9
10use 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
20/// Build the build-history report for one doc (`doc_rel_path` is relative to
21/// the repo root, e.g. `"docs/guide/intro.md"`). Returns `Ok(None)` when the
22/// doc has no commit history (graceful no-op).
23pub 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
31/// Like [`build_doc_diff_report`] but attaching the rendered block diff to each
32/// file. Used by consumers that actually render blocks; the CLI build/history
33/// path does not (it projects only hunks), so it uses the cheaper default.
34pub 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        // Build the block grouping (cheap text grouping) to reproduce the TS
59        // "no visible change -> return null" skip decision. Rendering each
60        // block to HTML is only done when a consumer wants the blocks — it is
61        // pure waste in the build/history path, which projects only hunks.
62        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        // Skip the file when there is no visible change (parity with the TS
74        // `return null`). In build-history each point is a single doc file.
75        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
118/// Build the global build-history report across all docs under `docs_prefix`
119/// (repo-relative, e.g. `"docs"`). Each timeline point is a commit carrying
120/// *every* doc file it changed — the analogue of the original global
121/// `/docs/diff` report. Returns `Ok(None)` when no commit touched the docs.
122/// With `with_blocks`, each file additionally carries its rendered block diff.
123pub 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            // Parity with the TS `return null`: a file whose only change is
144            // invisible (no hunks, all-context blocks) is dropped.
145            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        // A commit whose docs changes were all invisible yields no files — skip.
170        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        // Block html populated via docgen-core markdown.
278        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        // First commit (oldest) is an Added file with empty old side.
284        assert_eq!(report.timeline[1].files[0].status, DocDiffFileStatus::Added);
285
286        // Report head selection.
287        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        // Hunks/line stats are still computed and the change is not skipped.
301        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        // Blocks are not built/rendered in the build path — nothing consumes them.
306        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        // Commit 1: two docs added.
313        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        // Commit 2: edit a + add nested c in ONE commit.
317        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        // Head commit changed a.md (edit) and c.md (add) -> both present.
328        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        // Block html rendered for the detail report.
333        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        // File tree nests docs/sub.
338        assert!(!head.file_tree.is_empty());
339
340        // Selection points at the head commit + its first file.
341        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}