Skip to main content

docgen_diff/
git_parsing.rs

1//! `git --name-status` / untracked parsing — a faithful port of `git-parsing.ts`.
2//!
3//! The `is_doc_path` filter (`docs/` + `.md`/`.svx`) belongs to the worktree/P5
4//! path; P2's `report.rs` reads a single known doc path and does not depend on
5//! it. The port stays faithful (including `.svx` support) for P5 reuse.
6
7use crate::types::DocDiffFileStatus;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct NameStatusEntry {
11    pub status: DocDiffFileStatus,
12    pub path: String,
13    pub old_path: Option<String>,
14}
15
16/// Parse `git diff --name-status` output into doc-file change entries.
17pub fn parse_name_status(stdout: &str) -> Vec<NameStatusEntry> {
18    stdout
19        .split('\n')
20        .map(|line| line.trim())
21        .filter(|line| !line.is_empty())
22        .flat_map(parse_name_status_line)
23        .collect()
24}
25
26/// Parse `git ls-files --others` output, keeping only doc paths.
27pub fn parse_untracked_docs(stdout: &str) -> Vec<String> {
28    stdout
29        .split('\n')
30        .map(|line| line.trim())
31        .filter(|line| is_doc_path(line))
32        .map(|line| line.to_string())
33        .collect()
34}
35
36fn parse_name_status_line(line: &str) -> Vec<NameStatusEntry> {
37    let mut parts = line.split('\t');
38    let raw_status = parts.next().unwrap_or("");
39    let first_path = parts.next();
40    let second_path = parts.next();
41
42    match raw_status {
43        "A" => entry(DocDiffFileStatus::Added, first_path),
44        "M" => entry(DocDiffFileStatus::Modified, first_path),
45        "D" => entry(DocDiffFileStatus::Deleted, first_path),
46        s if s.starts_with('R') => {
47            let old_is_doc = is_doc_path_opt(first_path);
48            let new_is_doc = is_doc_path_opt(second_path);
49
50            if old_is_doc && new_is_doc {
51                return vec![NameStatusEntry {
52                    status: DocDiffFileStatus::Renamed,
53                    old_path: Some(first_path.unwrap().to_string()),
54                    path: second_path.unwrap().to_string(),
55                }];
56            }
57            if old_is_doc {
58                return vec![NameStatusEntry {
59                    status: DocDiffFileStatus::Deleted,
60                    path: first_path.unwrap().to_string(),
61                    old_path: None,
62                }];
63            }
64            if new_is_doc {
65                return vec![NameStatusEntry {
66                    status: DocDiffFileStatus::Added,
67                    path: second_path.unwrap().to_string(),
68                    old_path: None,
69                }];
70            }
71            vec![]
72        }
73        _ => vec![],
74    }
75}
76
77fn entry(status: DocDiffFileStatus, path: Option<&str>) -> Vec<NameStatusEntry> {
78    match path {
79        Some(p) if is_doc_path(p) => vec![NameStatusEntry {
80            status,
81            path: p.to_string(),
82            old_path: None,
83        }],
84        _ => vec![],
85    }
86}
87
88fn is_doc_path_opt(path: Option<&str>) -> bool {
89    path.map(is_doc_path).unwrap_or(false)
90}
91
92fn is_doc_path(path: &str) -> bool {
93    path.starts_with("docs/") && (path.ends_with(".md") || path.ends_with(".svx"))
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use DocDiffFileStatus::*;
100
101    fn ns(status: DocDiffFileStatus, path: &str, old_path: Option<&str>) -> NameStatusEntry {
102        NameStatusEntry {
103            status,
104            path: path.into(),
105            old_path: old_path.map(|s| s.into()),
106        }
107    }
108
109    #[test]
110    fn parses_added_modified_deleted_renamed() {
111        assert_eq!(
112            parse_name_status(
113                "A\tdocs/new.md\nM\tdocs/a.md\nD\tdocs/old.md\nR100\tdocs/from.md\tdocs/to.md\n"
114            ),
115            vec![
116                ns(Added, "docs/new.md", None),
117                ns(Modified, "docs/a.md", None),
118                ns(Deleted, "docs/old.md", None),
119                ns(Renamed, "docs/to.md", Some("docs/from.md")),
120            ]
121        );
122    }
123
124    #[test]
125    fn one_sided_docs_renames_map_to_add_or_delete() {
126        assert_eq!(
127            parse_name_status(
128                "R100\tdocs/from.md\toutside/from.md\nR100\toutside/to.md\tdocs/to.md\n"
129            ),
130            vec![
131                ns(Deleted, "docs/from.md", None),
132                ns(Added, "docs/to.md", None),
133            ]
134        );
135    }
136
137    #[test]
138    fn untracked_keeps_docs_md_and_svx() {
139        assert_eq!(
140            parse_untracked_docs("docs/a.md\ndocs/b.svx\nclient/nope.md\n"),
141            vec!["docs/a.md", "docs/b.svx"]
142        );
143    }
144}