Skip to main content

autograde_rs/
unit.rs

1use std::str::{from_utf8, Utf8Error};
2
3use log::debug;
4use miette::{Diagnostic, Report, SourceSpan};
5use serde::Deserialize;
6use similar::{ChangeTag, DiffOp, TextDiff};
7use thiserror::Error;
8use tokio::process::Command;
9
10use crate::config::Config;
11
12// use crate::build::BuildSystem;
13
14#[derive(Deserialize, Debug)]
15pub struct TestUnits {
16    pub tests: Vec<TestUnit>,
17}
18
19#[derive(Deserialize, Debug)]
20pub struct TestUnit {
21    name: String,
22    input: Vec<String>,
23    expected: String,
24    rubric: u64,
25}
26
27impl TestUnit {
28    /// Interpolate strings with '$' place holder
29    pub fn interpolate_config(
30        &mut self,
31        config: &Config,
32        executable: &str,
33    ) -> Result<(), UnitError> {
34        const PROJECT_DIR_SUBSTRING: &str = "$project";
35        const DIGITAL_JAR_SUBSTRING: &str = "$digital";
36        self.input
37            .iter_mut()
38            .map(|slice| {
39                if slice.contains(PROJECT_DIR_SUBSTRING) {
40                    *slice = slice.replace(PROJECT_DIR_SUBSTRING, executable)
41                } else if slice.contains(DIGITAL_JAR_SUBSTRING) {
42                    let digital_path = config.test.clone().unwrap().digital_path();
43                    *slice = match digital_path {
44                        Some(path) => path,
45                        None => {
46                            eprintln!("Error: digital_path is not set.");
47                            return Err(UnitError::DigitalJarPathNotSpecified);
48                        }
49                    };
50                }
51                Ok(())
52            })
53            .for_each(drop); // Consume the iterator
54        Ok(())
55    }
56}
57
58#[derive(Debug)]
59struct UnitOutput {
60    // output: String,
61    name: String,
62    grade: u64,
63    rubric: u64,
64}
65
66#[derive(Error, Diagnostic, Debug)]
67#[error("One or more tests failed")]
68pub struct UnitErrors {
69    #[source_code]
70    src: String,
71    #[related]
72    errors: Vec<UnitError>,
73}
74
75#[derive(Error, Diagnostic, Debug)]
76pub enum UnitError {
77    // #[error("Exit code wasn't zero")]
78    // NonZeroExit,
79    #[error("Program crashed")]
80    ProgramCrashed,
81    // #[error(transparent)]
82    // #[diagnostic(transparent)]
83    // IncorrectOutput(#[from] IncorrectOutput),
84    #[error("Output doesn't match expected result")]
85    IncorrectOutput,
86    #[error("Not UTF8")]
87    NotUtf8(Utf8Error),
88    #[error("Could not run program")]
89    Wrapped(std::io::Error),
90    #[error("Could not interpolate string")]
91    DigitalJarPathNotSpecified,
92}
93
94#[derive(Error, Diagnostic, Debug)]
95#[error("Output doesn't match expected result")]
96// #[diagnostic(
97//     help("")
98// )]
99#[diagnostic()]
100pub struct IncorrectOutput {
101    #[related]
102    span_list: Vec<IncorrectSpan>,
103}
104
105#[derive(Error, Diagnostic, Debug, Clone)]
106#[error("Want: {expected:?}, got: ")]
107struct IncorrectSpan {
108    expected: Option<String>,
109    #[source_code]
110    got: String,
111    #[label("here")]
112    at: SourceSpan,
113}
114
115// fn pull_tests() {}
116
117// #[allow(async_fn_in_trait)]
118// pub trait RunProject {
119//     async fn run(self) -> miette::Result<u64>;
120// }
121
122// #[allow(async_fn_in_trait)]
123// pub trait RunUnit {
124//     async fn run(&self) -> Result<TestOutput, UnitError>;
125// }
126
127// impl RunProject for TestUnits {
128impl TestUnits {
129    pub async fn run(self) -> miette::Result<u64> {
130        let mut tasks = Vec::with_capacity(self.tests.len());
131        for unit in self.tests {
132            tasks.push(tokio::spawn(unit.run()))
133        }
134
135        let mut outputs = Vec::with_capacity(tasks.len());
136        for task in tasks {
137            outputs.push(task.await.unwrap());
138        }
139
140        let grade: u64 = outputs
141            .into_iter()
142            .map(|out| match out {
143                Ok(out) => {
144                    println!("{}: ({}/{})", out.name, out.grade, out.rubric);
145                    out.grade
146                }
147                Err(e) => {
148                    let report = Report::new(e);
149                    eprintln!("{:?}", report);
150                    0
151                }
152            })
153            .sum();
154
155        Ok(grade)
156    }
157}
158
159// impl RunUnit for Unit {
160impl TestUnit {
161    async fn run(self) -> Result<UnitOutput, UnitError> {
162        let output = Command::new(self.input.first().expect("Empty input in tests file!"))
163            .args(
164                self.input
165                    .split_first()
166                    .expect("Empty input in tests file!")
167                    .1,
168            )
169            .output()
170            .await
171            .map_err(UnitError::Wrapped)?;
172
173        // TODO do we care about nonzero exits?
174        // if !output.status.success() {
175        // }
176
177        let stdout = from_utf8(&output.stdout)
178            .map_err(UnitError::NotUtf8)?
179            .trim();
180
181        let mut errors = vec![];
182        let diff = TextDiff::from_lines(self.expected.trim(), stdout);
183        for op in diff.ops() {
184            for change in diff.iter_changes(op) {
185                if change.tag() == ChangeTag::Equal || change.value() == "\n" {
186                    continue;
187                }
188
189                // println!("{:#?}", change);
190
191                errors.push(IncorrectSpan {
192                    expected: Some(self.expected.clone()),
193                    // .lines()
194                    // .nth(change.old_index().unwrap())
195                    // .map(|s| s.to_owned()),
196                    got: change.to_string(),
197                    at: (op.new_range().into()),
198                })
199            }
200        }
201
202        if errors.is_empty() {
203            // TODO change to actual partial grading?
204            Ok(UnitOutput {
205                name: self.name,
206                grade: self.rubric,
207                rubric: self.rubric,
208            })
209        } else {
210            Err(
211                UnitError::IncorrectOutput, //     (IncorrectOutput {
212                                            //     // src: stdout.into(),
213                                            //     span_list: errors,
214                                            // })
215            )
216        }
217    }
218}
219
220// tokio bug https://github.com/tokio-rs/tokio/pull/6874
221#[allow(clippy::needless_return)]
222#[tokio::test]
223async fn test_unit_run() -> miette::Result<()> {
224    use miette::IntoDiagnostic;
225
226    let test = TestUnit {
227        name: "".into(),
228        input: ["echo", "hello world"]
229            .iter_mut()
230            .map(|s| s.to_owned())
231            .collect(),
232        expected: "hello world".into(),
233        rubric: 100,
234    };
235    test.run().await.into_diagnostic().unwrap();
236
237    let test = TestUnit {
238        name: "".into(),
239        input: ["echo", "howdy y'all"]
240            .iter_mut()
241            .map(|s| s.to_owned())
242            .collect(),
243        expected: "hello world".into(),
244        rubric: 100,
245    };
246    // test.run().await?;
247    let res = test.run().await;
248    assert!(res.is_err());
249    println!("{:?}", res);
250
251    Ok(())
252}