use std::time::Duration;
use recall_hooks::client;
use recall_wire::evaluations::STATE_DONE;
use recall_wire::Finding;
use serde::{Deserialize, Serialize};
use super::Claim;
const DEADLINE: Duration = Duration::from_secs(20);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvalFinding {
pub evaluation: String,
pub finding: String,
pub kind: String,
pub severity: String,
}
#[derive(Debug)]
pub(super) enum Read {
NoServer,
Unreadable(String),
NoneYet,
Newest(String, Vec<Finding>),
}
pub(super) async fn newest(cfg: &recall_hooks::ClientConfig) -> Read {
if cfg.url.is_empty() {
return Read::NoServer;
}
let client = match crate::devices::admin_client(cfg) {
Ok(client) => client,
Err(why) => return Read::Unreadable(format!("reports are not readable here: {why}")),
};
let fetch = async {
let list = client.evaluations().await?;
let Some(done) = list.evaluations.into_iter().find(|e| e.state == STATE_DONE) else {
return Ok(None);
};
client.evaluation(&done.id).await.map(Some)
};
match tokio::time::timeout(DEADLINE, fetch).await {
Err(_) => Read::Unreadable(format!(
"the server did not list its reports within {}s",
DEADLINE.as_secs()
)),
Ok(Err(
e @ client::Error::Status {
code: 401 | 403, ..
},
)) => Read::Unreadable(format!(
"reports need a device enrolled as admin, or the server's RECALL_TOKEN ({})",
e.reason()
)),
Ok(Err(e)) => Read::Unreadable(format!("the server's reports: {}", e.reason())),
Ok(Ok(None)) => Read::NoneYet,
Ok(Ok(Some(evaluation))) => Read::Newest(evaluation.id, evaluation.findings),
}
}
pub(super) fn beside(
claims: &mut [Claim],
evaluation: &str,
findings: &[Finding],
scope_of: impl Fn(&str) -> Option<(String, String)>,
) {
for claim in claims {
let Some((key, path)) = scope_of(&claim.file) else {
continue;
};
claim.eval = findings
.iter()
.filter(|f| {
f.project_key == key
&& f.file_path == path
&& f.lines[0] <= claim.lines[1]
&& f.lines[1] >= claim.lines[0]
})
.map(|f| EvalFinding {
evaluation: evaluation.to_string(),
finding: f.id.clone(),
kind: f.kind.clone(),
severity: f.severity.clone(),
})
.collect();
}
}
#[cfg(test)]
mod tests {
use super::super::Class;
use super::*;
fn claim(file: &str, lines: [u32; 2]) -> Claim {
Claim {
id: "t1".into(),
file: file.into(),
lines,
class: Class::Present,
text: "x".into(),
verdict: None,
layer: None,
evidence: Vec::new(),
suggested_edit: None,
eval: Vec::new(),
}
}
fn finding(id: &str, key: &str, path: &str, lines: [u32; 2]) -> Finding {
Finding {
id: id.into(),
kind: "stale".into(),
severity: "low".into(),
project_key: key.into(),
file_path: path.into(),
lines,
related: Vec::new(),
}
}
#[test]
fn a_finding_is_beside_the_claims_it_covers() {
let mut claims = vec![
claim("deploy.md", [2, 3]),
claim("deploy.md", [5, 5]),
claim("global/editor.md", [1, 1]),
claim("deploy.md", [1, 1]),
];
let findings = [
finding("f1", "acme/app", "deploy.md", [3, 4]),
finding("f2", "acme/app", "other.md", [2, 3]),
finding("f3", "global", "editor.md", [1, 1]),
finding("f4", "acme/elsewhere", "deploy.md", [2, 2]),
];
beside(&mut claims, "eval_x", &findings, |file| {
Some(match file.strip_prefix("global/") {
Some(path) => ("global".to_string(), path.to_string()),
None => ("acme/app".to_string(), file.to_string()),
})
});
let ids = |c: &Claim| c.eval.iter().map(|e| e.finding.clone()).collect::<Vec<_>>();
assert_eq!(ids(&claims[0]), ["f1"]);
assert!(ids(&claims[1]).is_empty());
assert_eq!(ids(&claims[2]), ["f3"]);
assert!(ids(&claims[3]).is_empty(), "ends before the finding starts");
assert_eq!(claims[0].eval[0].evaluation, "eval_x");
}
}