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}