Local runner for GitHub autograder
1mod cli;
2mod grader;
3mod meta;
4mod runner;
5
6use std::time::Duration;
7
8use anyhow::Result;
9use clap::Parser;
10
11use cli::Cli;
12use grader::AutoGraderData;
13use indicatif::ProgressBar;
14
15use crate::{grader::ComparisonType, runner::TestResult};
16
17struct CmdOutput(String, String, Option<String>);
18
19impl CmdOutput {
20 fn format_output(self, name: &str, comp: &ComparisonType) -> String {
21 let CmdOutput(stdout, stderr, expected) = self;
22
23 let heading = format!("┏━┻ Stdout of {name}");
24 let stdout = stdout.trim().replace("\n", "\n┃ ");
25 let mid = format!("┣━━ Stderr of {name}");
26 let stderr = stderr.trim().replace("\n", "\n┃ ");
27 let (mid2, expected) = if let Some(expected) = expected {
28 (
29 format!("┣━━ Stdout of {name} must {comp}"),
30 expected.trim().replace("\n", "\n┃ "),
31 )
32 } else {
33 (format!("┣━━ {name} has no output check"), String::new())
34 };
35 let foot = "┗━━ End of output".to_string();
36
37 format!("{heading}\n┃ {stdout}\n{mid}\n┃ {stderr}\n{mid2}\n┃ {expected}\n{foot}")
38 }
39}
40
41#[derive(Debug)]
42enum TestOutput {
43 Passed,
44 LogicError,
45 NonZeroExit(Option<i32>),
46 RunnerError(anyhow::Error),
47 Skipped,
48}
49
50impl From<Result<(bool, TestResult)>> for TestOutput {
51 fn from(res: Result<(bool, TestResult)>) -> Self {
52 match res {
53 Ok((matched, TestResult { status: Ok(0), .. })) => {
54 if matched {
55 Self::Passed
56 } else {
57 Self::LogicError
58 }
59 }
60 Ok((_, TestResult { status, .. })) => Self::NonZeroExit(status.ok()),
61 Err(why) => Self::RunnerError(why),
62 }
63 }
64}
65
66impl TestOutput {
67 fn output(&self) -> (&str, String) {
68 match self {
69 TestOutput::Passed => ("✅", "Passed".to_string()),
70 TestOutput::LogicError => ("❌", "Did not match expected output".to_string()),
71 TestOutput::NonZeroExit(code) => (
72 "💥",
73 format!("Exited with code {}", code.unwrap_or(i32::MAX)),
74 ),
75 TestOutput::RunnerError(error) => ("🙈", format!("Wasn't able to run: {error:?}")),
76 TestOutput::Skipped => ("〰️", "Was skipped".to_string()),
77 }
78 }
79}
80
81fn main() -> Result<()> {
82 let cli = Cli::parse();
83
84 if cli.man_gen {
85 println!("{}", meta::gen_man_page());
86 return Ok(());
87 } else if let Some(shell) = cli.completions {
88 println!("{}", meta::gen_completions(shell.parse().unwrap()));
89 return Ok(());
90 }
91
92 let target_test = cli.test.as_ref().map(|s| s.to_lowercase());
93
94 let grader_data = AutoGraderData::get(cli.file)?;
95
96 let outputs = grader_data
97 .tests
98 .into_iter()
99 .enumerate()
100 .map(|(i, test)| {
101 // In verbose mode make the output a bit easier to follow
102 if cli.verbose {
103 println!();
104 }
105 let bar = ProgressBar::new_spinner();
106 bar.set_message(format!("Running {}", test.name));
107 bar.enable_steady_tick(Duration::from_millis(100));
108
109 let skip_number = cli.skip.map(|skip| i < skip).unwrap_or(false);
110
111 let skip_non_target = target_test
112 .as_ref()
113 .is_some_and(|s| *s != test.name.to_lowercase());
114
115 let (output, verbose_output) = if skip_number || skip_non_target {
116 (TestOutput::Skipped, None)
117 } else {
118 let result = test.run();
119 let verbose_output =
120 result
121 .as_ref()
122 .ok()
123 .map(|(_, TestResult { stdout, stderr, .. })| {
124 CmdOutput(stdout.clone(), stderr.clone(), test.output.clone())
125 });
126 (result.into(), verbose_output)
127 };
128
129 let (emoji, msg) = output.output();
130
131 bar.finish_with_message(format!("{emoji} {} - {msg}", test.name));
132
133 if let (Some(verbose_output), true) = (verbose_output, cli.verbose) {
134 println!(
135 "{}",
136 verbose_output.format_output(&test.name, &test.comparison)
137 );
138 }
139
140 output
141 })
142 .collect::<Vec<_>>();
143
144 let total_tests = outputs.len();
145 let total_not_skipped = outputs
146 .iter()
147 .filter(|o| !matches!(o, TestOutput::Skipped))
148 .count();
149 let total_passed = outputs
150 .iter()
151 .filter(|o| matches!(o, TestOutput::Passed))
152 .count();
153
154 let percent_passed = total_passed as f32 / total_not_skipped as f32 * 100_f32;
155
156 println!("\n== TEST SUMMARY ==");
157 println!("{total_passed} / {total_not_skipped} Tests Passed ({percent_passed:.2}%)");
158 if total_tests != total_not_skipped {
159 println!("({} Tests Were Skipped)", total_tests - total_not_skipped);
160 }
161
162 Ok(())
163}