jj_status_parser/
lib.rs

1use serde::Serialize;
2use std::fmt::Display;
3use std::path::PathBuf;
4use std::str::FromStr;
5use winnow::Result;
6use winnow::ascii::{newline, space0, space1};
7use winnow::combinator::{alt, separated};
8use winnow::combinator::{opt, seq};
9use winnow::prelude::*;
10use winnow::token::{rest, take_till, take_until, take_while};
11use winnow_parse_error::ParseError;
12
13const EMPTY_DESCRIPTION: &str = "(no description set)";
14
15#[derive(Debug, PartialEq, Eq, Serialize)]
16enum FileStatus {
17    Added,
18    Modified,
19    Removed,
20}
21
22impl Display for FileStatus {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        let symbol = match self {
25            FileStatus::Added => 'A',
26            FileStatus::Modified => 'M',
27            FileStatus::Removed => 'R',
28        };
29        write!(f, "{symbol}")
30    }
31}
32
33fn file_status(s: &mut &str) -> Result<FileStatus> {
34    alt((
35        'A'.map(|_| FileStatus::Added),
36        'R'.map(|_| FileStatus::Removed),
37        'M'.map(|_| FileStatus::Modified),
38    ))
39    .parse_next(s)
40}
41
42#[derive(Debug, PartialEq, Eq, Serialize)]
43pub struct WorkingCopyChange {
44    status: FileStatus,
45    path: PathBuf,
46}
47
48impl Display for WorkingCopyChange {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        write!(f, "{} {}", self.status, self.path.display())
51    }
52}
53
54fn part<'a>(s: &mut &'a str) -> Result<&'a str> {
55    take_till(1.., |c: char| c == '/' || c == '\n').parse_next(s)
56}
57
58fn path(s: &mut &str) -> Result<PathBuf> {
59    let parts: Vec<&str> = separated(1.., part, "/").parse_next(s)?;
60    let path: PathBuf = parts.iter().collect();
61    Ok(path)
62}
63
64fn file_change(s: &mut &str) -> Result<WorkingCopyChange> {
65    seq! {WorkingCopyChange {
66        status: file_status,
67        _: space1,
68        path: path
69    }}
70    .parse_next(s)
71}
72
73fn file_changes(s: &mut &str) -> Result<Vec<WorkingCopyChange>> {
74    separated(0.., file_change, "\n").parse_next(s)
75}
76
77#[derive(Debug, PartialEq, Eq, Serialize)]
78pub struct CommitDetails {
79    change_id: String,
80    commit_id: String,
81    empty: bool,
82    bookmark: Option<String>,
83    description: Option<String>,
84}
85
86impl CommitDetails {
87    pub fn change_id(&self) -> &str {
88        &self.change_id.as_str()
89    }
90
91    pub fn commit_id(&self) -> &str {
92        &self.commit_id.as_str()
93    }
94
95    pub fn empty(&self) -> bool {
96        self.empty
97    }
98
99    pub fn bookmark(&self) -> Option<&String> {
100        self.bookmark.as_ref()
101    }
102
103    pub fn description(&self) -> &str {
104        match &self.description {
105            Some(description) => description.as_str(),
106            None => EMPTY_DESCRIPTION,
107        }
108    }
109}
110
111impl Display for CommitDetails {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        let empty = if self.empty { "(empty)" } else { "" };
114        let bookmark = match &self.bookmark {
115            Some(bookmark) => {
116                format!("{bookmark} | ")
117            }
118            None => String::new(),
119        };
120        let description = match &self.description {
121            Some(description) => &description,
122            None => EMPTY_DESCRIPTION,
123        };
124        write!(
125            f,
126            "{} {} {empty}{bookmark}{description}",
127            self.change_id, self.commit_id
128        )
129    }
130}
131
132fn char_between_inclusive(c: char, lower: char, upper: char) -> bool {
133    c >= lower && c <= upper
134}
135
136fn change_id(s: &mut &str) -> Result<String> {
137    take_while(1.., |c: char| char_between_inclusive(c, 'k', 'z'))
138        .map(|s: &str| s.to_string())
139        .parse_next(s)
140}
141
142fn commit_id(s: &mut &str) -> Result<String> {
143    take_while(1.., |c: char| {
144        char_between_inclusive(c, '0', '9') || char_between_inclusive(c, 'a', 'f')
145    })
146    .map(|s: &str| s.to_string())
147    .parse_next(s)
148}
149
150fn bookmark(s: &mut &str) -> Result<String> {
151    let bookmark = take_until(1.., " |")
152        .map(|x: &str| x.to_string())
153        .parse_next(s)?;
154    let _ = " |".parse_next(s)?;
155    Ok(bookmark)
156}
157
158fn description(s: &mut &str) -> Result<Option<String>> {
159    alt((
160        "(no description set)".map(|_| None),
161        alt((take_till(1.., |c: char| c == '\n'), rest)).map(|s: &str| Some(s.to_string())),
162    ))
163    .parse_next(s)
164}
165
166fn empty(s: &mut &str) -> Result<bool> {
167    opt("(empty) ")
168        .map(|x| match x {
169            Some(_) => true,
170            None => false,
171        })
172        .parse_next(s)
173}
174
175fn commit_details(s: &mut &str) -> Result<CommitDetails> {
176    seq! {CommitDetails {
177        change_id: change_id,
178        _: space1,
179        commit_id: commit_id,
180        _: space1,
181        empty: empty,
182        _: space0,
183        bookmark: opt(bookmark),
184        _: space0,
185        description: description,
186    }}
187    .parse_next(s)
188}
189
190#[derive(Debug, PartialEq, Eq, Serialize)]
191pub struct Status {
192    file_changes: Vec<WorkingCopyChange>,
193    working_copy: Commit,
194    parent_commit: Commit,
195}
196
197impl Status {
198    pub fn file_changes(&self) -> &[WorkingCopyChange] {
199        &self.file_changes.as_ref()
200    }
201
202    pub fn working_copy(&self) -> &Commit {
203        &self.working_copy
204    }
205
206    pub fn parent_commit(&self) -> &Commit {
207        &self.parent_commit
208    }
209}
210
211fn working_copy(s: &mut &str) -> Result<Commit> {
212    let _ = "Working copy".parse_next(s)?;
213    let _ = space1.parse_next(s)?;
214    let _ = ":".parse_next(s)?;
215    let _ = space1.parse_next(s)?;
216    commit_details
217        .map(|details| Commit::WorkingCopy(details))
218        .parse_next(s)
219}
220
221fn parent_commit(s: &mut &str) -> Result<Commit> {
222    let _ = "Parent commit:".parse_next(s)?;
223    let _ = space1.parse_next(s)?;
224    commit_details
225        .map(|details| Commit::ParentCommit(details))
226        .parse_next(s)
227}
228
229#[derive(Debug, PartialEq, Eq, Serialize)]
230#[serde(tag = "change_type")]
231pub enum Commit {
232    WorkingCopy(CommitDetails),
233    ParentCommit(CommitDetails),
234}
235
236impl Commit {
237    pub fn change_id(&self) -> &str {
238        match self {
239            Self::WorkingCopy(details) | Self::ParentCommit(details) => details.change_id(),
240        }
241    }
242
243    pub fn commit_id(&self) -> &str {
244        match self {
245            Self::WorkingCopy(details) | Self::ParentCommit(details) => details.commit_id(),
246        }
247    }
248
249    pub fn empty(&self) -> bool {
250        match self {
251            Self::WorkingCopy(details) | Self::ParentCommit(details) => details.empty(),
252        }
253    }
254
255    pub fn bookmark(&self) -> Option<&String> {
256        match self {
257            Self::WorkingCopy(details) | Self::ParentCommit(details) => details.bookmark(),
258        }
259    }
260
261    pub fn description(&self) -> &str {
262        match self {
263            Self::WorkingCopy(details) | Self::ParentCommit(details) => details.description(),
264        }
265    }
266}
267
268impl Display for Commit {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        match self {
271            Self::WorkingCopy(details) => {
272                write!(f, "{details}")
273            }
274            Self::ParentCommit(details) => {
275                write!(f, "{details}")
276            }
277        }
278    }
279}
280
281fn status(s: &mut &str) -> Result<Status> {
282    seq! {Status {
283        _: "Working copy changes:",
284        _: newline,
285        file_changes: file_changes,
286        _: newline,
287        working_copy: working_copy,
288        _: newline,
289        parent_commit: parent_commit,
290    }}
291    .parse_next(s)
292}
293
294impl FromStr for Status {
295    type Err = ParseError;
296
297    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
298        status.parse(s).map_err(|e| ParseError::from_parse(e))
299    }
300}
301
302impl Display for Status {
303    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304        for change in &self.file_changes {
305            write!(f, "{change}")?;
306        }
307        write!(f, "Working copy : {}", self.working_copy)?;
308        write!(f, "Parent commit: {}", self.parent_commit)?;
309        Ok(())
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use pretty_assertions::assert_eq;
317
318    const HEADER: &str = "Working copy changes:";
319    const FILE1: &str = "A src/lib.rs";
320    const FILE2: &str = "A src/main.rs";
321    const WORKING: &str = "Working copy : qnxonnkx 60be3879 main | (no description set)";
322    const PARENT: &str = "Parent commit: zzzzzzzz 00000000 (empty) (no description set)";
323
324    #[test]
325    fn test_parse_change_id() {
326        let mut input = "qnxonnkx";
327        let expected = String::from("qnxonnkx");
328        let actual = change_id(&mut input);
329        assert_eq!(Ok(expected), actual);
330        assert_eq!("", input);
331    }
332
333    #[test]
334    fn test_parse_commit_id() {
335        let mut input = "60be3879";
336        let expected = String::from("60be3879");
337        let actual = commit_id(&mut input);
338        assert_eq!(Ok(expected), actual);
339        assert_eq!("", input);
340    }
341
342    #[test]
343    fn test_parse_file_change() {
344        let mut input = FILE1;
345        let expected = WorkingCopyChange {
346            status: FileStatus::Added,
347            path: PathBuf::from("src/lib.rs"),
348        };
349        let actual = file_change(&mut input);
350        assert_eq!(Ok(expected), actual);
351        assert_eq!("", input);
352    }
353
354    #[test]
355    fn test_parse_file_changes() {
356        let input = [FILE1, FILE2].join("\n");
357        let mut input = input.as_str();
358
359        let expected = vec![
360            WorkingCopyChange {
361                status: FileStatus::Added,
362                path: PathBuf::from("src/lib.rs"),
363            },
364            WorkingCopyChange {
365                status: FileStatus::Added,
366                path: PathBuf::from("src/main.rs"),
367            },
368        ];
369        let actual = file_changes(&mut input);
370        assert_eq!(Ok(expected), actual);
371        assert_eq!("", input);
372    }
373
374    #[test]
375    fn test_parse_details_1() {
376        let mut input = "qnxonnkx 60be3879 main | (no description set)";
377        let expected = CommitDetails {
378            change_id: String::from("qnxonnkx"),
379            commit_id: String::from("60be3879"),
380            empty: false,
381            bookmark: Some(String::from("main")),
382            description: None,
383        };
384        let actual = commit_details(&mut input);
385        assert_eq!(Ok(expected), actual)
386    }
387
388    #[test]
389    fn test_parse_details_2() {
390        let mut input = "zzzzzzzz 00000000 (empty) (no description set)";
391        let expected = CommitDetails {
392            change_id: String::from("zzzzzzzz"),
393            commit_id: String::from("00000000"),
394            empty: true,
395            bookmark: None,
396            description: None,
397        };
398        let actual = commit_details(&mut input);
399        assert_eq!(Ok(expected), actual)
400    }
401
402    #[test]
403    fn test_parse_working_copy() {
404        let mut input = WORKING;
405        let expected = Commit::WorkingCopy(CommitDetails {
406            change_id: String::from("qnxonnkx"),
407            commit_id: String::from("60be3879"),
408            empty: false,
409            bookmark: Some(String::from("main")),
410            description: None,
411        });
412        let actual = working_copy(&mut input);
413        assert_eq!(Ok(expected), actual);
414        assert_eq!("", input);
415    }
416
417    #[test]
418    fn test_parse_empty_description() {
419        let mut input = "(no description set)";
420        let expected = None;
421        let actual = description(&mut input);
422        assert_eq!(Ok(expected), actual);
423        assert_eq!("", input);
424    }
425
426    #[test]
427    fn test_parse_parent_commit() {
428        let mut input = PARENT;
429        let expected = Commit::ParentCommit(CommitDetails {
430            change_id: String::from("zzzzzzzz"),
431            commit_id: String::from("00000000"),
432            empty: true,
433            bookmark: None,
434            description: None,
435        });
436        let actual = parent_commit(&mut input);
437        assert_eq!(Ok(expected), actual);
438        assert_eq!("", input);
439    }
440
441    #[test]
442    fn test_status_from_str() {
443        let input = [HEADER, FILE1, FILE2, WORKING, PARENT].join("\n");
444
445        let expected = Status {
446            file_changes: vec![
447                WorkingCopyChange {
448                    status: FileStatus::Added,
449                    path: PathBuf::from("src/lib.rs"),
450                },
451                WorkingCopyChange {
452                    status: FileStatus::Added,
453                    path: PathBuf::from("src/main.rs"),
454                },
455            ],
456            working_copy: Commit::WorkingCopy(CommitDetails {
457                change_id: String::from("qnxonnkx"),
458                commit_id: String::from("60be3879"),
459                empty: false,
460                bookmark: Some(String::from("main")),
461                description: None,
462            }),
463            parent_commit: Commit::ParentCommit(CommitDetails {
464                change_id: String::from("zzzzzzzz"),
465                commit_id: String::from("00000000"),
466                empty: true,
467                bookmark: None,
468                description: None,
469            }),
470        };
471        let actual = Status::from_str(&input);
472        assert_eq!(Ok(expected), actual);
473    }
474}