use std::path::Path;
use std::time::Duration;
use crate::toolchain::Toolchain;
use super::exec::{head_and_tail, Exec, ExecOutcome};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Outcome {
Found(String),
Clean,
Skipped(String),
Failed(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Format {
CargoJson,
Rendered,
}
const CHECKERS: &[(&str, &[&str], Format)] = &[
(
"cargo",
&["cargo", "check", "--message-format=json"],
Format::CargoJson,
),
("node", &["tsc", "--noEmit"], Format::Rendered),
("deno", &["deno", "check", "."], Format::Rendered),
("go", &["go", "build", "./..."], Format::Rendered),
("python", &["pyright"], Format::Rendered),
];
pub(crate) async fn after_edit(
root: &Path,
tc: Option<&Toolchain>,
timeout: Duration,
cap: usize,
) -> Outcome {
let Some(tc) = tc else {
return Outcome::Skipped("no project marker in the workspace root".into());
};
let Some((_, argv, format)) = CHECKERS.iter().find(|(eco, _, _)| *eco == tc.ecosystem) else {
return Outcome::Skipped(format!(
"no check command cheap enough to run after every edit for a {} project",
tc.ecosystem
));
};
let argv: Vec<String> = argv.iter().map(|s| (*s).to_string()).collect();
run(root, &argv, *format, timeout, cap).await
}
async fn run(
root: &Path,
argv: &[String],
format: Format,
timeout: Duration,
cap: usize,
) -> Outcome {
let outcome = Exec::new(root, timeout, usize::MAX).run(argv).await;
let (code, stdout, stderr) = match outcome {
Ok(ExecOutcome::Ran {
code,
stdout,
stderr,
..
}) => (code, stdout, stderr),
Ok(ExecOutcome::TimedOut { after }) => {
return Outcome::Failed(format!(
"`{}` did not finish within {}s, so this edit is unchecked",
argv.join(" "),
after.as_secs()
));
}
Ok(ExecOutcome::Unavailable { reason }) => {
return Outcome::Failed(format!(
"this edit is unchecked: {reason}, so `{}` could not run",
argv.join(" ")
));
}
Err(e) => {
return Outcome::Failed(format!(
"this edit is unchecked: `{}` could not be started ({e})",
argv.join(" ")
));
}
};
let findings = match format {
Format::CargoJson => cargo_rendered(&stdout),
Format::Rendered => {
let s = if stderr.trim().is_empty() {
&stdout
} else {
&stderr
};
s.trim().to_string()
}
};
if findings.trim().is_empty() {
return match code {
Some(0) => Outcome::Clean,
other => Outcome::Failed(format!(
"this edit is unchecked: `{}` exited with {} and reported nothing",
argv.join(" "),
other.map_or_else(|| "a signal".to_string(), |c| c.to_string())
)),
};
}
let (text, _elided) = head_and_tail(&findings, cap);
Outcome::Found(format!(
"Diagnostics from `{}`, run over the whole workspace after this edit. They are \
the project's own checker talking, and they may name files this edit did not \
touch:\n{text}",
argv.join(" ")
))
}
fn cargo_rendered(stdout: &str) -> String {
let mut out = String::new();
for line in stdout.lines() {
let line = line.trim();
if !line.starts_with('{') {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if v.get("reason").and_then(serde_json::Value::as_str) != Some("compiler-message") {
continue;
}
let Some(rendered) = v
.pointer("/message/rendered")
.and_then(serde_json::Value::as_str)
else {
continue;
};
out.push_str(rendered.trim_end());
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const CAP: usize = 100_000;
fn toolchain(ecosystem: &str) -> Toolchain {
Toolchain {
ecosystem: ecosystem.to_string(),
marker: "marker".into(),
manager: ecosystem.to_string(),
install: vec![],
build: vec![],
test: vec![],
lint: vec![],
format: vec![],
run: vec![],
}
}
#[tokio::test]
async fn an_unknown_ecosystem_is_a_skip_and_spawns_nothing() {
let dir = tempfile::tempdir().unwrap();
let none = after_edit(dir.path(), None, Duration::ZERO, CAP).await;
assert!(
matches!(none, Outcome::Skipped(_)),
"a root with no marker has nothing to check: {none:?}"
);
for eco in ["make", "maven", "gradle", "dotnet", "swift", "ruby"] {
let out = after_edit(dir.path(), Some(&toolchain(eco)), Duration::ZERO, CAP).await;
assert!(
matches!(out, Outcome::Skipped(_)),
"{eco} has no cheap checker, which is a skip and not a failure: {out:?}"
);
}
}
#[tokio::test]
async fn a_checker_missing_from_path_is_a_failure_not_an_error_and_not_a_clean_check() {
let dir = tempfile::tempdir().unwrap();
let argv = vec!["io-harness-no-such-checker".to_string()];
let out = run(
dir.path(),
&argv,
Format::Rendered,
Duration::from_secs(30),
CAP,
)
.await;
let Outcome::Failed(reason) = &out else {
panic!("a missing checker is a Failed outcome: {out:?}");
};
assert!(reason.contains("io-harness-no-such-checker"), "{reason}");
assert_ne!(
out,
Outcome::Clean,
"F9: unchecked is not the same as clean"
);
}
#[tokio::test]
async fn a_checker_that_finds_nothing_is_clean_and_not_a_skip() {
let dir = tempfile::tempdir().unwrap();
let argv = vec!["rustc".to_string(), "--version".to_string()];
let out = run(
dir.path(),
&argv,
Format::CargoJson,
Duration::from_secs(60),
CAP,
)
.await;
assert_eq!(
out,
Outcome::Clean,
"exit 0 with no diagnostics is a clean check"
);
}
#[test]
fn the_parser_takes_rendered_from_compiler_messages_and_ignores_everything_else() {
let stream = concat!(
"cargo:rerun-if-changed=build.rs\n",
"warning: something a proc macro printed\n",
r#"{"reason":"compiler-artifact","target":{"name":"io-harness"},"fresh":false}"#,
"\n",
r#"{"reason":"build-script-executed","package_id":"x 0.1.0"}"#,
"\n",
r#"{"reason":"compiler-message","message":{"level":"error","rendered":"error[E0308]: mismatched types\n --> src/x.rs:3:5\n","spans":[]}}"#,
"\n",
r#"{"reason":"compiler-message","message":{"level":"warning","rendered":"warning: unused variable: `n`\n"}}"#,
"\n",
r#"{"reason":"compiler-message","message":{"level":"error"}}"#,
"\n",
r#"{"reason":"compiler-mess"#,
"\n",
r#"{"reason":"build-finished","success":false}"#,
"\n",
);
let out = cargo_rendered(stream);
assert!(out.contains("error[E0308]: mismatched types"), "{out}");
assert!(out.contains("--> src/x.rs:3:5"), "{out}");
assert!(out.contains("warning: unused variable: `n`"), "{out}");
assert!(!out.contains("compiler-artifact"), "{out}");
assert!(!out.contains("rerun-if-changed"), "{out}");
assert!(!out.contains("proc macro"), "{out}");
assert!(!out.contains("build-finished"), "{out}");
assert!(!out.contains("\"reason\""), "{out}");
assert_eq!(
out.lines().count(),
3,
"two lines of the E0308 block and one of the warning, and nothing else: {out}"
);
}
#[test]
fn a_stream_with_no_diagnostics_in_it_renders_to_nothing() {
let clean = concat!(
r#"{"reason":"compiler-artifact","target":{"name":"x"}}"#,
"\n",
r#"{"reason":"build-finished","success":true}"#,
"\n",
);
assert_eq!(cargo_rendered(clean), "");
}
#[tokio::test]
async fn the_findings_are_capped_and_say_what_they_dropped() {
let dir = tempfile::tempdir().unwrap();
let argv = vec![
"rustc".to_string(),
"io-harness-no-such-file.rs".to_string(),
];
let out = run(
dir.path(),
&argv,
Format::Rendered,
Duration::from_secs(60),
20,
)
.await;
let Outcome::Found(text) = &out else {
panic!("rustc reports a missing input on stderr: {out:?}");
};
assert!(
text.contains("characters elided"),
"the cap was applied and the model is told: {text}"
);
let whole = run(
dir.path(),
&argv,
Format::Rendered,
Duration::from_secs(60),
CAP,
)
.await;
let Outcome::Found(text) = &whole else {
panic!("{whole:?}");
};
assert!(!text.contains("characters elided"), "{text}");
assert!(text.contains("io-harness-no-such-file.rs"), "{text}");
}
#[tokio::test]
async fn a_checker_that_exits_nonzero_saying_nothing_is_unchecked_rather_than_clean() {
let dir = tempfile::tempdir().unwrap();
let argv = vec![
"rustc".to_string(),
"io-harness-no-such-file.rs".to_string(),
];
let out = run(
dir.path(),
&argv,
Format::CargoJson,
Duration::from_secs(60),
CAP,
)
.await;
assert!(matches!(out, Outcome::Failed(_)), "{out:?}");
}
#[test]
fn every_checker_in_the_table_has_a_program_and_a_distinct_ecosystem() {
let mut seen = Vec::new();
for (eco, argv, _) in CHECKERS {
assert!(!argv.is_empty(), "{eco} has no program");
assert!(!seen.contains(eco), "{eco} appears twice: first match wins");
seen.push(eco);
}
let json: Vec<_> = CHECKERS
.iter()
.filter(|(_, _, f)| *f == Format::CargoJson)
.collect();
assert_eq!(json.len(), 1, "only cargo's stream needs a parser");
}
}