use std::collections::BTreeMap;
use std::path::PathBuf;
use argonauts_core::events::RunEventKind;
use argonauts_core::events::session::{InputPart, InputPartKind};
use argonauts_core::events::task::{CheckKind, CheckOutcome};
use argonauts_core::limits::START_CHECKS;
use argonauts_core::settings::protocol::CheckCommand;
use argonauts_core::{Note, NoteLevel, TaskId};
use crate::engine::host::Host;
use crate::error::{EngineError, EngineResult};
use crate::state::RunState;
use crate::task::checks;
use crate::task::worktree::TaskTree;
pub const ALREADY_FAILING: &str = "already failing";
const TAIL_CHARS: usize = 1_500;
pub async fn ensure(host: &Host) -> EngineResult<()> {
let commands = &host.settings().checks.run;
if commands.is_empty() {
return Ok(());
}
let mut unknown = host.shared().start_checks.lock().await;
if *unknown || host.read(|state| !state.tasks.at_start.is_empty()) {
return Ok(());
}
let Some((scope, copy)) = copy_of_the_start(host).await? else {
*unknown = true;
return Ok(());
};
for command in commands {
let request = checks::request(host, &scope, command, ©, false)?;
host.check(request).await?;
}
if let Some(line) = host.read(said) {
warn(host, NoteLevel::Info, &line)?;
}
Ok(())
}
pub async fn outcome_of(host: &Host, command: &CheckCommand) -> EngineResult<Option<CheckOutcome>> {
ensure(host).await?;
let known = |state: &RunState| {
let ran = |check: &&CheckOutcome| {
check.name == command.name && check.command.as_ref() == Some(&command.command)
};
state.tasks.at_start.iter().find(ran).cloned()
};
let mut unknown = host.shared().start_checks.lock().await;
if let Some(known) = host.read(known) {
return Ok(Some(known));
}
if *unknown {
return Ok(None);
}
let Some((scope, copy)) = copy_of_the_start(host).await? else {
*unknown = true;
return Ok(None);
};
let request = checks::request(host, &scope, command, ©, false)?;
Ok(Some(host.check(request).await?))
}
async fn copy_of_the_start(host: &Host) -> EngineResult<Option<(TaskId, PathBuf)>> {
let base = host.read(|state| {
let started = state.run.started.as_ref();
started.map(|started| started.base_commit.clone())
});
let Some(base) = base else {
return Ok(None);
};
let scope = TaskId::new(START_CHECKS).map_err(|err| EngineError::Refused(err.to_string()))?;
let tree = TaskTree {
path: host.repo().worktrees.join(START_CHECKS),
branch: "the starting code".to_owned(),
base: base.clone(),
};
let copy = checks::clean_copy_at(host, &tree, &base).await?;
if let Some(output) = checks::setup(host, &scope, ©, &tree.branch).await? {
let line = "checks.setup failed on the starting code, so what already fails there isn't \
known and every task answers for every check";
warn(host, NoteLevel::Warn, &format!("{line}: {output}"))?;
return Ok(None);
}
Ok(Some((scope, copy)))
}
fn warn(host: &Host, level: NoteLevel, text: &str) -> EngineResult<()> {
let note = Note {
level,
text: text.to_owned(),
};
host.record(None, None, RunEventKind::Note(note))?;
Ok(())
}
pub fn failing(state: &RunState) -> BTreeMap<&str, &CheckOutcome> {
let mut by_name = BTreeMap::new();
let commands = state
.tasks
.at_start
.iter()
.filter(|check| check.kind == CheckKind::Command && check.command.is_some());
for check in commands {
by_name.entry(check.name.as_str()).or_insert(check);
}
by_name.retain(|_, check| !check.passed);
by_name
}
fn lines(state: &RunState) -> Vec<String> {
failing(state)
.values()
.map(|check| {
let ran = check
.command
.as_ref()
.map(|command| format!(" ({})", command.join(" ")))
.unwrap_or_default();
format!("{}{ran}: {}", check.name, check.summary.trim())
})
.collect()
}
fn said(state: &RunState) -> Option<String> {
let lines = lines(state);
(!lines.is_empty()).then(|| {
format!(
"Already failing before the run changed anything: {}. Each task answers only for \
what it breaks, and the final check still runs everything.",
lines.join("; ")
)
})
}
pub fn note(state: &RunState) -> Option<String> {
let failing = failing(state);
if failing.is_empty() {
return None;
}
let mut text = String::from(
"Already failing on the code this run started from, before anything changed:\n",
);
for (line, check) in lines(state).iter().zip(failing.values()) {
text.push_str(&format!("- {line}\n"));
let tail = check.output_tail.trim();
if !tail.is_empty() {
let from = tail
.char_indices()
.rev()
.nth(TAIL_CHARS.saturating_sub(1))
.map_or(0, |(at, _)| at);
text.push_str(&format!("```\n{}\n```\n", &tail[from..]));
}
}
text.push_str(
"Nothing in this run broke these. When the request needs one fixed, the plan gives that \
fix to one task and never rules it out. No other task is failed for them, and none \
should work around them.",
);
Some(text)
}
pub async fn part(host: &Host) -> EngineResult<Option<InputPart>> {
ensure(host).await?;
let part = |text| crate::task::support::part(InputPartKind::Notes, ALREADY_FAILING, text, None);
Ok(host.read(note).map(part))
}
pub fn review_lines(state: &RunState) -> Vec<String> {
lines(state)
.into_iter()
.map(|line| {
format!(
"at the start, before this run: {line}. It failed already, so the change under \
review isn't answerable for it"
)
})
.collect()
}
pub fn judge(mut outcome: CheckOutcome, at_start: Option<&CheckOutcome>) -> CheckOutcome {
let Some(at_start) = at_start.filter(|at_start| !at_start.passed && !outcome.passed) else {
return outcome;
};
let same = match (failures(&at_start.summary), failures(&outcome.summary)) {
(Some(before), Some(now)) => now <= before,
(None, None) => {
let said = outcome.output_tail.trim();
!said.is_empty()
&& said == at_start.output_tail.trim()
&& at_start.summary.trim() == outcome.summary.trim()
}
_ => false,
};
if same {
outcome.passed = true;
outcome.summary = format!("{}, as at the start", outcome.summary.trim());
} else {
outcome.summary = format!(
"{} (at the start: {})",
outcome.summary.trim(),
at_start.summary.trim()
);
}
outcome
}
pub(crate) fn failures(summary: &str) -> Option<u64> {
let mut found = None;
for piece in summary.split([',', ';']) {
let mut words = piece.split_whitespace();
let (Some(count), Some(what)) = (words.next(), words.next()) else {
continue;
};
let Ok(count) = count.parse::<u64>() else {
continue;
};
let what = what.to_lowercase();
if what.starts_with("fail") || what.starts_with("error") {
found = Some(found.unwrap_or(0) + count);
} else if what.starts_with("pass") && found.is_none() {
found = Some(0);
}
}
found
}
#[cfg(test)]
mod tests {
use super::*;
fn outcome(passed: bool, summary: &str) -> CheckOutcome {
CheckOutcome {
name: "tests".to_owned(),
kind: CheckKind::Command,
passed,
command: Some(vec!["npm".to_owned(), "test".to_owned()]),
exit_code: Some(i32::from(!passed)),
duration_ms: 5,
summary: summary.to_owned(),
output_tail: String::new(),
violations: Vec::new(),
sandboxed: false,
skipped: false,
}
}
#[test]
fn a_summary_says_how_many_failed() {
assert_eq!(failures("15 passed, 1 failed"), Some(1));
assert_eq!(failures("41 passed; 2 failed; 1 ignored"), Some(2));
assert_eq!(failures("3 failed, 1 errors, 9 passed"), Some(4));
assert_eq!(failures("9 passed"), Some(0));
assert_eq!(failures("failed with exit code 1"), None);
assert_eq!(failures("timed out after 500ms"), None);
}
#[test]
fn a_task_answers_only_for_what_it_broke() {
let at_start = outcome(false, "15 passed, 1 failed");
let same = judge(outcome(false, "16 passed, 1 failed"), Some(&at_start));
assert!(same.passed);
assert_eq!(same.summary, "16 passed, 1 failed, as at the start");
assert_eq!(same.exit_code, Some(1));
let broke = judge(outcome(false, "14 passed, 2 failed"), Some(&at_start));
assert!(!broke.passed);
assert_eq!(
broke.summary,
"14 passed, 2 failed (at the start: 15 passed, 1 failed)"
);
let unknown = judge(outcome(false, "failed with exit code 1"), Some(&at_start));
assert!(!unknown.passed);
let passing = outcome(true, "16 passed");
assert!(!judge(outcome(false, "15 passed, 1 failed"), Some(&passing)).passed);
assert!(!judge(outcome(false, "15 passed, 1 failed"), None).passed);
assert!(judge(outcome(true, "17 passed"), Some(&at_start)).passed);
let said = |text: &str| CheckOutcome {
output_tail: text.to_owned(),
..outcome(false, "failed with exit code 1")
};
let plain = said("lint: 2 problems in docs/");
assert!(judge(said("lint: 2 problems in docs/"), Some(&plain)).passed);
assert!(!judge(said("lint: 3 problems in docs/"), Some(&plain)).passed);
assert!(!judge(said(""), Some(&said(""))).passed);
}
}