cargo_regression/
regression.rs

1use core::fmt;
2use std::{
3  io,
4  path::PathBuf,
5  process::{ExitCode, Termination},
6  sync::Arc,
7  time::Instant,
8};
9
10use itertools::{Either, Itertools};
11use tokio::{fs::remove_dir_all, sync::Semaphore};
12
13use crate::{
14  args::match_extension,
15  assert::{AssertError, DisplayErrs},
16  config::FullConfig,
17  Args,
18};
19
20#[derive(Debug, thiserror::Error)]
21pub enum BuildError {
22  #[error("file \"{0}\": {1}")]
23  Toml(PathBuf, toml::de::Error),
24  #[error("task \"{0}\": its permit = {1}, exceed total permits = {2}")]
25  PermitEcxceed(PathBuf, u32, u32),
26  #[error("task \"{0}\": need to specify '{1}'")]
27  MissConfig(PathBuf, &'static str),
28  #[error("file \"{0}\": {1}")]
29  UnableToRead(PathBuf, io::Error),
30  #[error("read dir \"{0}\": {1}")]
31  ReadDir(PathBuf, io::Error),
32  #[error("clean dir \"{0}\": {1}")]
33  CleanDir(PathBuf, io::Error),
34  #[error("input extensions can not contains 'toml'")]
35  InputExtToml,
36  #[error("This is regolden mode, all test case will pass!")]
37  Regolden,
38}
39
40pub(crate) enum FailedState {
41  ReportSaved(PathBuf),
42  NoReport(PathBuf, Vec<AssertError>),
43}
44pub(crate) enum State {
45  Ok,
46  Failed(Option<FailedState>),
47  Ignored,
48  FilteredOut,
49}
50
51impl fmt::Display for FailedState {
52  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53    match self {
54      Self::ReportSaved(report) => {
55        write!(f, "\n     report: {}", report.display())
56      }
57      Self::NoReport(input, errs) => {
58        write!(f, "\n----------- {} -----------\n{}", input.display(), DisplayErrs(errs))
59      }
60    }
61  }
62}
63impl fmt::Display for State {
64  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65    match self {
66      Self::Ok => write!(f, "\x1B[32mok\x1B[0m"),
67      Self::Failed(_) => write!(f, "\x1B[31mFAILED\x1B[0m"),
68      Self::Ignored => write!(f, "\x1B[33mignored\x1B[0m"),
69      Self::FilteredOut => write!(f, "\x1B[2mfiltered out\x1B[0m"),
70    }
71  }
72}
73
74pub(crate) struct TestResult {
75  count_ok: usize,
76  count_ignored: usize,
77  count_filtered: usize,
78  faileds: Vec<FailedState>,
79}
80
81pub struct TestExitCode(Result<TestResult, Vec<BuildError>>, Instant);
82
83impl Termination for TestExitCode {
84  fn report(self) -> ExitCode {
85    let time = self.1.elapsed().as_secs_f32();
86    match self.0 {
87      Ok(TestResult { count_ok, count_ignored, count_filtered, faileds }) => {
88        if faileds.is_empty() {
89          println!("test result: {}. {count_ok} passed; 0 failed; {count_ignored} ignored; {count_filtered} filtered out; finished in {time:.2}s", State::Ok);
90          ExitCode::SUCCESS
91        } else {
92          print!("\nfailures:");
93          for failed in &faileds {
94            print!("{failed}");
95          }
96          println!("\n\ntest result: {}. {count_ok} passed; {} failed; {count_ignored} ignored; {count_filtered} filtered out; finished in {time:.2}s", State::Failed(None), faileds.len());
97          ExitCode::FAILURE
98        }
99      }
100      Err(build_errs) => {
101        println!("Fail to build test:");
102        for err in &build_errs {
103          println!("{err}");
104        }
105        ExitCode::FAILURE
106      }
107    }
108  }
109}
110
111impl Args {
112  pub async fn test(self) -> TestExitCode {
113    let now = Instant::now();
114    TestExitCode(
115      match self.rebuild() {
116        Ok(args) => _test(args).await,
117        Err(e) => Err(vec![e]),
118      },
119      now,
120    )
121  }
122}
123async fn _test(args: Args) -> Result<TestResult, Vec<BuildError>> {
124  let f1 = async move {
125    let work_dir = PathBuf::from(args.work_dir);
126    if work_dir.exists() {
127      remove_dir_all(&work_dir)
128        .await
129        .map_err(|e| BuildError::CleanDir(work_dir, e))
130    } else {
131      Ok(())
132    }
133  };
134  let f2 =
135    async move { walk(FullConfig::new(args), PathBuf::from(args.root_dir), args).await };
136  // walkthrough all config
137  let (clean_dir, file_configs) = tokio::join!(f1, f2);
138  if let Err(e) = clean_dir {
139    return Err(vec![e]);
140  }
141  let scheduler = Arc::new(Semaphore::new(args.permits as usize));
142  let handles: Vec<_> = file_configs?
143    .into_iter()
144    .map(|(path, config)| {
145      let scheduler = scheduler.clone();
146      tokio::spawn(async move {
147        let _permit = scheduler
148          .acquire_many(*config.permit)
149          .await
150          .expect("Semaphore closed");
151        let state = config.test(&path, args).await;
152        println!("test {} ... {}", path.display(), state);
153        state
154      })
155    })
156    .collect();
157
158  let mut count_ok = 0;
159  let mut count_ignored = 0;
160  let mut count_filtered = 0;
161  let mut faileds = Vec::with_capacity(handles.len());
162  // TODO: iter
163  for handle in handles {
164    match handle.await.unwrap() {
165      State::Ok => count_ok += 1,
166      State::Failed(Some(failed)) => faileds.push(failed),
167      State::Failed(None) => unreachable!(),
168      State::Ignored => count_ignored += 1,
169      State::FilteredOut => count_filtered += 1,
170    }
171  }
172  scheduler.close();
173  if args.regolden {
174    return Err(vec![BuildError::Regolden]);
175  }
176  Ok(TestResult { count_ok, count_ignored, count_filtered, faileds })
177}
178
179#[async_recursion::async_recursion]
180async fn walk(
181  mut current_config: FullConfig,
182  current_path: PathBuf,
183  args: Args,
184) -> Result<Vec<(PathBuf, FullConfig)>, Vec<BuildError>> {
185  let all_path = current_path.join("__all__.toml");
186  if all_path.exists() {
187    match current_config.update(&all_path, args.debug) {
188      Ok(_config) => current_config = _config,
189      Err(e) => return Err(vec![e]),
190    }
191  }
192  let read_dir = match current_path.read_dir() {
193    Ok(read_dir) => read_dir,
194    Err(e) => return Err(vec![BuildError::ReadDir(current_path, e)]),
195  };
196  let (sub_dir_futures, files): (Vec<_>, Vec<_>) =
197    read_dir.into_iter().partition_map(|entry| {
198      let path = entry.unwrap().path();
199      if path.is_dir() {
200        let current_config = current_config.clone();
201        Either::Left(tokio::spawn(async move { walk(current_config, path, args).await }))
202      } else {
203        Either::Right(path)
204      }
205    });
206  let mut errs = Vec::new();
207  let mut file_configs = files
208    .into_iter()
209    .filter_map(|file| {
210      if match_extension(&file, current_config.extensions.iter()) {
211        match args.filtered(&file) {
212          Ok(filtered) => {
213            if filtered {
214              Some((file, FullConfig::new_filtered()))
215            } else {
216              let config_file = file.with_extension("toml");
217              let current_config = current_config.clone();
218              if config_file.is_file() {
219                match current_config.update(&config_file, args.debug) {
220                  Ok(config) => Some((file, config)),
221                  Err(e) => {
222                    errs.push(e);
223                    None
224                  }
225                }
226              } else {
227                Some((file, current_config))
228              }
229              .and_then(|(file, config)| match config.eval(&file, args) {
230                Ok(config) => Some((file, config)),
231                Err(e) => {
232                  errs.push(e);
233                  None
234                }
235              })
236            }
237          }
238          Err(e) => {
239            errs.push(e);
240            None
241          }
242        }
243      } else {
244        None
245      }
246    })
247    .collect::<Vec<_>>();
248  for f in sub_dir_futures.into_iter() {
249    match f.await.expect("join handle") {
250      Ok(res) => file_configs.extend(res),
251      Err(e) => errs.extend(e),
252    }
253  }
254  if errs.is_empty() {
255    Ok(file_configs)
256  } else {
257    Err(errs)
258  }
259}