cargo_geiger/
readme.rs

1use crate::args::ReadmeArgs;
2
3use cargo::{CliError, CliResult};
4use regex::Regex;
5use std::fs::File;
6use std::io::{BufRead, BufReader, Error, Write};
7use std::path::{Path, PathBuf};
8
9/// Name of README FILE
10pub const README_FILENAME: &str = "README.md";
11/// Safety report section
12const CARGO_GEIGER_SAFETY_REPORT_SECTION_HEADER: &str =
13    "## Cargo Geiger Safety Report";
14
15/// Taking a `PathBuf` pointing to the README location, and a `&Vec<String>` containing the result
16/// of a scan, either create a section containing the scan result if one does not exist, or replace
17/// the section if it already exists
18pub fn create_or_replace_section_in_readme(
19    readme_args: &ReadmeArgs,
20    scan_output_lines: &[String],
21) -> CliResult {
22    let readme_path_buf =
23        get_readme_path_buf_from_arguments_or_default(readme_args);
24
25    if !readme_path_buf.exists() {
26        eprintln!(
27            "File: {} does not exist. To construct a Cargo Geiger Safety Report section, please first create a README.",
28            readme_path_buf.to_str().unwrap()
29        );
30        return CliResult::Err(CliError::code(1));
31    }
32
33    let mut readme_content =
34        read_file_contents(&readme_path_buf).map_err(|e| {
35            eprintln!(
36                "Failed to read contents from file: {}",
37                readme_path_buf.to_str().unwrap()
38            );
39            anyhow::Error::from(e)
40        })?;
41
42    update_readme_content(readme_args, &mut readme_content, scan_output_lines);
43
44    write_lines_to_file(&readme_content, &readme_path_buf).map_err(|e| {
45        eprintln!(
46            "Failed to write lines to file: {}",
47            readme_path_buf.to_str().unwrap()
48        );
49        anyhow::Error::from(e)
50    })?;
51
52    Ok(())
53}
54
55/// For a `&Vec<String` find the index of the first and last lines of a Safety Report Section. If
56/// the Section is not present, -1 is returned for both values, and if the Section is the last
57/// section present, then the last index is -1
58fn find_start_and_end_lines_of_safety_report_section(
59    readme_args: &ReadmeArgs,
60    readme_content: &[String],
61) -> (i32, i32) {
62    let mut start_line_number = -1;
63    let mut end_line_number = -1;
64
65    let start_line_pattern =
66        construct_regex_expression_for_section_header(readme_args);
67
68    let end_line_pattern = Regex::new("^#+.*").unwrap();
69
70    for (line_number, line) in readme_content.iter().enumerate() {
71        if start_line_pattern.is_match(line) {
72            start_line_number = line_number as i32;
73            continue;
74        }
75
76        if start_line_number != -1 && end_line_pattern.is_match(line) {
77            end_line_number = line_number as i32;
78            break;
79        }
80    }
81
82    (start_line_number, end_line_number)
83}
84
85/// Constructs a regex expression for the Section Name if provided as an argument,
86/// otherwise returns a regex expression for the default Section Name
87fn construct_regex_expression_for_section_header(
88    readme_args: &ReadmeArgs,
89) -> Regex {
90    match &readme_args.section_name {
91        Some(section_name) => {
92            let mut regex_string = String::from("^#+\\s");
93            regex_string.push_str(&section_name.replace(' ', "\\s"));
94            regex_string.push_str("\\s*");
95
96            Regex::new(&regex_string).unwrap()
97        }
98        None => {
99            Regex::new("^#+\\sCargo\\sGeiger\\sSafety\\sReport\\s*").unwrap()
100        }
101    }
102}
103
104/// Returns the `PathBuf` passed in as an argument value if one exists, otherwise
105/// returns the `PathBuf` to a file `README.md` in the current directory
106fn get_readme_path_buf_from_arguments_or_default(
107    readme_args: &ReadmeArgs,
108) -> PathBuf {
109    match &readme_args.readme_path {
110        Some(readme_path) => readme_path.to_path_buf(),
111        None => {
112            let mut current_dir_path_buf = std::env::current_dir().unwrap();
113            current_dir_path_buf.push(README_FILENAME);
114            current_dir_path_buf
115        }
116    }
117}
118
119/// Read the contents of a file line by line.
120fn read_file_contents(path: &Path) -> Result<Vec<String>, Error> {
121    let file = File::open(path)?;
122    let buf_reader = BufReader::new(file);
123
124    Ok(buf_reader
125        .lines()
126        .filter_map(|l| l.ok())
127        .collect::<Vec<String>>())
128}
129
130/// Update the content of a README.md with a Scan Result. When the section doesn't exist, it will
131/// be created with an `h2` level header, otherwise it will preserve the level of the existing
132/// header
133fn update_readme_content(
134    readme_args: &ReadmeArgs,
135    readme_content: &mut Vec<String>,
136    scan_result: &[String],
137) {
138    let (start_line_number, mut end_line_number) =
139        find_start_and_end_lines_of_safety_report_section(
140            readme_args,
141            readme_content,
142        );
143
144    if start_line_number == -1 {
145        // When Cargo Geiger Safety Report isn't present in README, add an
146        // h2 headed section at the end of the README.md containing the report
147        match &readme_args.section_name {
148            Some(section_name) => {
149                let mut section_name_string = String::from("## ");
150                section_name_string.push_str(section_name);
151
152                readme_content.push(section_name_string);
153            }
154            None => {
155                readme_content.push(
156                    CARGO_GEIGER_SAFETY_REPORT_SECTION_HEADER.to_string(),
157                );
158            }
159        }
160        readme_content.push(String::from("```"));
161        for scan_result_line in scan_result {
162            readme_content.push(scan_result_line.to_string())
163        }
164        readme_content.push(String::from("```"));
165    } else {
166        if end_line_number == -1 {
167            end_line_number = readme_content.len() as i32;
168        }
169
170        // When Cargo Geiger Safety Report is present in README, remove the
171        // section and and replace, preserving header level (h1/h2/h3)
172        for _ in start_line_number + 1..end_line_number {
173            readme_content.remove((start_line_number + 1) as usize);
174        }
175
176        let mut running_scan_line_index = start_line_number + 1;
177
178        readme_content
179            .insert(running_scan_line_index as usize, String::from("```"));
180        running_scan_line_index += 1;
181
182        for scan_result_line in scan_result {
183            readme_content.insert(
184                running_scan_line_index as usize,
185                scan_result_line.to_string(),
186            );
187            running_scan_line_index += 1;
188        }
189
190        readme_content
191            .insert(running_scan_line_index as usize, String::from("```"));
192    }
193}
194
195/// Write a Vec<String> line by line to a file, overwriting the current file, if it exists.
196fn write_lines_to_file(lines: &[String], path: &Path) -> Result<(), Error> {
197    let mut readme_file = File::create(path)?;
198
199    for line in lines {
200        writeln!(readme_file, "{}", line)?
201    }
202
203    Ok(())
204}
205
206#[cfg(test)]
207mod readme_tests {
208    use super::*;
209
210    use rstest::*;
211    use std::io::Write;
212    use tempfile::tempdir;
213
214    #[rstest]
215    fn create_or_replace_section_test_readme_doesnt_exist() {
216        let temp_dir = tempdir().unwrap();
217        let readme_path = temp_dir.path().join("README.md");
218
219        let readme_args = ReadmeArgs {
220            readme_path: Some(readme_path),
221            ..Default::default()
222        };
223
224        let scan_result = vec![];
225
226        let result =
227            create_or_replace_section_in_readme(&readme_args, &scan_result);
228
229        assert!(result.is_err());
230    }
231
232    #[rstest]
233    fn create_or_replace_section_test_readme_doesnt_contain_section() {
234        let temp_dir = tempdir().unwrap();
235        let readme_path = temp_dir.path().join("README.md");
236
237        let readme_args = ReadmeArgs {
238            readme_path: Some(readme_path.clone()),
239            ..Default::default()
240        };
241
242        let mut readme_file = File::create(readme_path.clone()).unwrap();
243        let scan_result = vec![
244            String::from("First safety report line"),
245            String::from("Second safety report line"),
246            String::from("Third safety report line"),
247        ];
248
249        writeln!(
250            readme_file,
251            "# Readme Header\nSome text\nAnother line\n## Another header\nMore text"
252        ).unwrap();
253
254        let result =
255            create_or_replace_section_in_readme(&readme_args, &scan_result);
256
257        assert!(result.is_ok());
258
259        let updated_file_content =
260            BufReader::new(File::open(readme_path).unwrap())
261                .lines()
262                .map(|l| l.unwrap())
263                .collect::<Vec<String>>();
264
265        let expected_readme_content = vec![
266            String::from("# Readme Header"),
267            String::from("Some text"),
268            String::from("Another line"),
269            String::from("## Another header"),
270            String::from("More text"),
271            CARGO_GEIGER_SAFETY_REPORT_SECTION_HEADER.to_string(),
272            String::from("```"),
273            String::from("First safety report line"),
274            String::from("Second safety report line"),
275            String::from("Third safety report line"),
276            String::from("```"),
277        ];
278
279        assert_eq!(updated_file_content, expected_readme_content)
280    }
281
282    #[rstest(
283        input_readme_args,
284        expected_regex_expression,
285        case(
286            ReadmeArgs{
287                section_name: None,
288                ..Default::default()
289            },
290            Regex::new("^#+\\sCargo\\sGeiger\\sSafety\\sReport\\s*").unwrap()
291        ),
292        case(
293            ReadmeArgs{
294                section_name: Some(String::from("Test Section Name")),
295                ..Default::default()
296            },
297            Regex::new("^#+\\sTest\\sSection\\sName\\s*").unwrap()
298        )
299    )]
300    fn construct_regex_expression_for_section_header_test(
301        input_readme_args: ReadmeArgs,
302        expected_regex_expression: Regex,
303    ) {
304        let regex_expression =
305            construct_regex_expression_for_section_header(&input_readme_args);
306
307        assert_eq!(
308            regex_expression.as_str(),
309            expected_regex_expression.as_str()
310        );
311    }
312
313    #[rstest(
314        input_readme_content,
315        expected_start_line_number,
316        expected_end_line_number,
317        case(
318            vec![
319                String::from("## Cargo Geiger Safety Report"),
320                String::from("First line"),
321                String::from("Second line")
322            ],
323            0,
324            -1
325        ),
326        case(
327            vec![
328                String::from("# Cargo Geiger Safety Report"),
329                String::from("First line"),
330                String::from("Second line")
331            ],
332            0,
333            -1
334        ),
335        case(
336            vec![
337                String::from("First line"),
338                String::from("## Cargo Geiger Safety Report"),
339                String::from("Second line"),
340                String::from("Third line")
341            ],
342            1,
343            -1
344        ),
345        case(
346            vec![
347                String::from("# Another header"),
348                String::from("First line"),
349                String::from("## Cargo Geiger Safety Report"),
350                String::from("Second line"),
351                String::from("Third line")
352            ],
353            2,
354            -1
355        ),
356        case(
357            vec![
358                String::from("# Another header"),
359                String::from("First line"),
360                String::from("## Cargo Geiger Safety Report"),
361                String::from("Second line"),
362                String::from("Third line"),
363                String::from("# Next header"),
364                String::from("Fourth line")
365            ],
366            2,
367            5
368        )
369    )]
370    fn find_start_and_end_lines_of_safety_report_section_test(
371        input_readme_content: Vec<String>,
372        expected_start_line_number: i32,
373        expected_end_line_number: i32,
374    ) {
375        let readme_args = ReadmeArgs::default();
376
377        let (start_line_number, end_line_number) =
378            find_start_and_end_lines_of_safety_report_section(
379                &readme_args,
380                &input_readme_content,
381            );
382
383        assert_eq!(start_line_number, expected_start_line_number);
384        assert_eq!(end_line_number, expected_end_line_number);
385    }
386
387    #[rstest]
388    fn get_readme_path_buf_from_arguments_or_default_test_none() {
389        let mut path_buf = std::env::current_dir().unwrap();
390        path_buf.push(README_FILENAME);
391
392        let readme_args = ReadmeArgs {
393            readme_path: None,
394            ..Default::default()
395        };
396
397        let readme_path_buf =
398            get_readme_path_buf_from_arguments_or_default(&readme_args);
399
400        assert_eq!(readme_path_buf, path_buf)
401    }
402
403    #[rstest]
404    fn get_readme_path_buf_from_arguments_or_default_test_some() {
405        let path_buf = PathBuf::from("/test/path");
406
407        let readme_args = ReadmeArgs {
408            readme_path: Some(path_buf.clone()),
409            ..Default::default()
410        };
411
412        let readme_path_buf =
413            get_readme_path_buf_from_arguments_or_default(&readme_args);
414
415        assert_eq!(readme_path_buf, path_buf);
416    }
417
418    #[rstest(
419        input_readme_args,
420        expected_section_header,
421        case(
422            ReadmeArgs{
423                section_name: None,
424                ..Default::default()
425            },
426            CARGO_GEIGER_SAFETY_REPORT_SECTION_HEADER.to_string()
427        ),
428        case(
429            ReadmeArgs{
430                section_name: Some(String::from("Test Section Name")),
431                ..Default::default()
432            },
433            String::from("## Test Section Name")
434        )
435    )]
436    fn update_readme_content_test_no_safety_report_present(
437        input_readme_args: ReadmeArgs,
438        expected_section_header: String,
439    ) {
440        let mut readme_content = vec![
441            String::from("# readme header"),
442            String::from("line of text"),
443            String::from("another line of text"),
444            String::from("## another header"),
445        ];
446
447        let scan_result = vec![
448            String::from("first line of scan result"),
449            String::from("second line of scan result"),
450            String::from("third line of scan result"),
451        ];
452
453        update_readme_content(
454            &input_readme_args,
455            &mut readme_content,
456            &scan_result,
457        );
458
459        let expected_readme_content = vec![
460            String::from("# readme header"),
461            String::from("line of text"),
462            String::from("another line of text"),
463            String::from("## another header"),
464            expected_section_header,
465            String::from("```"),
466            String::from("first line of scan result"),
467            String::from("second line of scan result"),
468            String::from("third line of scan result"),
469            String::from("```"),
470        ];
471
472        assert_eq!(readme_content, expected_readme_content);
473    }
474
475    #[rstest]
476    fn update_readme_content_test_safety_report_present_in_middle_of_readme() {
477        let readme_args = ReadmeArgs::default();
478
479        let mut readme_content = vec![
480            String::from("# readme header"),
481            String::from("line of text"),
482            String::from("another line of text"),
483            CARGO_GEIGER_SAFETY_REPORT_SECTION_HEADER.to_string(),
484            String::from("first line of old scan result"),
485            String::from("second line of old scan result"),
486            String::from("# another header"),
487            String::from("line of text"),
488        ];
489
490        let scan_result = vec![
491            String::from("first line of scan result"),
492            String::from("second line of scan result"),
493            String::from("third line of scan result"),
494        ];
495
496        update_readme_content(&readme_args, &mut readme_content, &scan_result);
497
498        let expected_readme_content = vec![
499            String::from("# readme header"),
500            String::from("line of text"),
501            String::from("another line of text"),
502            CARGO_GEIGER_SAFETY_REPORT_SECTION_HEADER.to_string(),
503            String::from("```"),
504            String::from("first line of scan result"),
505            String::from("second line of scan result"),
506            String::from("third line of scan result"),
507            String::from("```"),
508            String::from("# another header"),
509            String::from("line of text"),
510        ];
511
512        assert_eq!(readme_content, expected_readme_content);
513    }
514}