use std::collections::{BTreeMap, BTreeSet};
use lex_vcs::{
render_signature, render_type_signature, Acceptance, ApiChangeKind, ApiEntry, Attestation,
AttestationId, AttestationKind, AttestationResult, IntentLog, Issue, IssueId, IssueLog, OpLog,
ProducerDescriptor,
};
use serde::Serialize;
use crate::render::demangled_head_stages;
use crate::store::{Store, StoreError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueEvaluation {
Passed,
Failed { detail: String },
NotEvaluable { reason: String },
}
impl IssueEvaluation {
pub fn failed(detail: impl Into<String>) -> Self {
IssueEvaluation::Failed { detail: detail.into() }
}
pub fn not_evaluable(reason: impl Into<String>) -> Self {
IssueEvaluation::NotEvaluable { reason: reason.into() }
}
pub fn is_passed(&self) -> bool {
matches!(self, IssueEvaluation::Passed)
}
}
pub fn evaluate_static(
store: &Store,
issue: &Issue,
head_op: &str,
) -> Result<IssueEvaluation, StoreError> {
match &issue.acceptance {
Acceptance::FreeForm {} => Ok(IssueEvaluation::not_evaluable(
"free_form: human-closed, not machine-evaluable",
)),
Acceptance::MetricInvariant { .. } => Ok(IssueEvaluation::not_evaluable(
"metric_invariant: the metric/invariant oracle evaluator lands in #954",
)),
Acceptance::Evidence { .. } => Ok(IssueEvaluation::not_evaluable(
"evidence: the evidence oracle evaluator lands in #954",
)),
Acceptance::FailingExample { .. } => Ok(IssueEvaluation::Passed),
Acceptance::TypedDelta { api, .. } => {
check_api_delta(store, issue.base.as_deref(), head_op, api)
}
}
}
pub fn check_api_delta(
store: &Store,
base: Option<&str>,
head_op: &str,
api: &[ApiEntry],
) -> Result<IssueEvaluation, StoreError> {
let head = surface(&demangled_head_stages(store, head_op)?);
let base_surface = match base {
Some(b) => Some(surface(&demangled_head_stages(store, b)?)),
None => None,
};
let mut problems: Vec<String> = Vec::new();
for e in api {
let want = squash(&e.signature);
let at_head = head
.get(&e.name)
.and_then(|r| tail_after_name(r, &e.name))
.map(|t| squash(&t));
let at_base = base_surface
.as_ref()
.and_then(|b| b.get(&e.name))
.and_then(|r| tail_after_name(r, &e.name))
.map(|t| squash(&t));
match e.kind {
ApiChangeKind::Added => {
match &at_head {
None => problems.push(format!("`{}`: declared added, but absent at head", e.name)),
Some(h) if *h != want => problems.push(format!(
"`{}`: signature at head `{}` differs from declared `{}`",
e.name, h, want
)),
_ => {}
}
if at_base.is_some() {
problems.push(format!("`{}`: declared added, but already present at base", e.name));
}
}
ApiChangeKind::Changed => {
match &at_head {
None => problems.push(format!("`{}`: declared changed, but absent at head", e.name)),
Some(h) if *h != want => problems.push(format!(
"`{}`: signature at head `{}` differs from declared `{}`",
e.name, h, want
)),
_ => {}
}
if base_surface.is_some() {
match &at_base {
None => problems.push(format!("`{}`: declared changed, but absent at base", e.name)),
Some(b) if *b == want => problems.push(format!(
"`{}`: declared changed, but base already had this signature",
e.name
)),
_ => {}
}
}
}
ApiChangeKind::Removed => {
if at_head.is_some() {
problems.push(format!("`{}`: declared removed, but still present at head", e.name));
}
if base_surface.is_some() && at_base.is_none() {
problems.push(format!("`{}`: declared removed, but was absent at base", e.name));
}
}
}
}
if problems.is_empty() {
Ok(IssueEvaluation::Passed)
} else {
Ok(IssueEvaluation::failed(problems.join("; ")))
}
}
pub fn prepare_example_stages(
store: &Store,
head_op: &str,
cases: &[(String, lex_ast::Example)],
) -> Result<Vec<lex_ast::Stage>, StoreError> {
let mut stages = demangled_head_stages(store, head_op)?;
for st in &mut stages {
if let lex_ast::Stage::FnDecl(fd) = st {
fd.examples.clear();
}
}
for (name, example) in cases {
let target = stages.iter_mut().find_map(|st| match st {
lex_ast::Stage::FnDecl(fd) if &fd.name == name => Some(fd),
_ => None,
});
match target {
Some(fd) => fd.examples.push(example.clone()),
None => return Err(StoreError::IssueTarget(name.clone())),
}
}
Ok(stages)
}
pub fn record_issue_verdict(
store: &Store,
issue: &Issue,
head_op: &str,
evaluation: &IssueEvaluation,
) -> Result<AttestationId, StoreError> {
let result = match evaluation {
IssueEvaluation::Passed => AttestationResult::Passed,
IssueEvaluation::Failed { detail } => AttestationResult::Failed { detail: detail.clone() },
IssueEvaluation::NotEvaluable { reason } => {
AttestationResult::Inconclusive { detail: reason.clone() }
}
};
let attestation = Attestation::new(
issue.issue_id.clone(),
Some(head_op.to_string()),
None,
AttestationKind::IssueVerified {
issue_id: issue.issue_id.clone(),
shape: issue.acceptance.shape().to_string(),
},
result,
issue_gate_producer(),
None,
);
store.attestation_log()?.put(&attestation)?;
Ok(attestation.attestation_id.clone())
}
pub fn issue_verdicts(store: &Store, issue_id: &str) -> Result<Vec<Attestation>, StoreError> {
let all = store.attestation_log()?.list_for_stage(&issue_id.to_string())?;
Ok(all
.into_iter()
.filter(|a| matches!(a.kind, AttestationKind::IssueVerified { .. }))
.collect())
}
pub fn is_verified(store: &Store, issue_id: &str) -> Result<bool, StoreError> {
let latest = issue_verdicts(store, issue_id)?
.into_iter()
.max_by_key(|a| a.timestamp);
Ok(matches!(latest.map(|a| a.result), Some(AttestationResult::Passed)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueState {
Open,
InProgress,
Verified,
Blocked,
}
#[derive(Debug, Clone, Serialize)]
pub struct IssueStatus {
pub issue: Issue,
pub state: IssueState,
pub blocked_on: Vec<IssueId>,
pub has_work: bool,
}
pub fn issues_in_progress(store: &Store) -> Result<BTreeSet<IssueId>, StoreError> {
let log = OpLog::open(store.root())?;
let intents = IntentLog::open(store.root())?;
let mut out = BTreeSet::new();
let mut seen_intents: BTreeSet<String> = BTreeSet::new();
for branch in store.list_branches()? {
let Some(head) = store.get_branch(&branch).ok().flatten().and_then(|b| b.head_op) else {
continue;
};
for rec in log.walk_forward(&head, None)? {
let Some(iid) = rec.op.intent_id.clone() else { continue };
if !seen_intents.insert(iid.clone()) {
continue;
}
if let Some(intent) = intents.get(&iid)? {
if let Some(issue_id) = intent.issue_id {
out.insert(issue_id);
}
}
}
}
Ok(out)
}
pub fn issue_status(
store: &Store,
issue: &Issue,
in_progress: &BTreeSet<IssueId>,
) -> Result<IssueStatus, StoreError> {
let verified = is_verified(store, &issue.issue_id)?;
let mut blocked_on = Vec::new();
for dep in &issue.deps {
if !is_verified(store, dep)? {
blocked_on.push(dep.clone());
}
}
let has_work = in_progress.contains(&issue.issue_id);
let state = if verified {
IssueState::Verified
} else if !blocked_on.is_empty() {
IssueState::Blocked
} else if has_work {
IssueState::InProgress
} else {
IssueState::Open
};
Ok(IssueStatus { issue: issue.clone(), state, blocked_on, has_work })
}
pub fn all_issue_status(store: &Store) -> Result<Vec<IssueStatus>, StoreError> {
let log = IssueLog::open(store.root())?;
let in_progress = issues_in_progress(store)?;
let mut out = Vec::new();
for id in log.list_ids()? {
if let Some(issue) = log.get(&id)? {
out.push(issue_status(store, &issue, &in_progress)?);
}
}
Ok(out)
}
fn issue_gate_producer() -> ProducerDescriptor {
ProducerDescriptor {
tool: "lex-store::issue-gate".into(),
version: env!("CARGO_PKG_VERSION").into(),
model: None,
}
}
fn surface(stages: &[lex_ast::Stage]) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for st in stages {
match st {
lex_ast::Stage::FnDecl(fd) => {
out.insert(fd.name.clone(), render_signature(fd));
}
lex_ast::Stage::TypeDecl(td) => {
out.insert(td.name.clone(), render_type_signature(td));
}
lex_ast::Stage::Import(_) => {}
}
}
out
}
fn tail_after_name(rendered: &str, name: &str) -> Option<String> {
let rest = rendered
.strip_prefix("fn ")
.or_else(|| rendered.strip_prefix("type "))?;
let tail = rest.strip_prefix(name)?;
Some(tail.trim().to_string())
}
fn squash(s: &str) -> String {
s.chars().filter(|c| !c.is_whitespace()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tail_strips_keyword_and_name() {
assert_eq!(
tail_after_name("fn gcd(a :: Int, b :: Int) -> Int", "gcd").as_deref(),
Some("(a :: Int, b :: Int) -> Int")
);
assert_eq!(tail_after_name("type Shape = A | B", "Shape").as_deref(), Some("= A | B"));
assert_eq!(tail_after_name("fn gcd(a :: Int) -> Int", "lcm"), None);
}
#[test]
fn squash_ignores_whitespace_only() {
assert_eq!(squash("(a :: Int, b :: Int) -> Int"), squash("(a::Int,b::Int)->Int"));
assert_ne!(squash("(a :: Int) -> Int"), squash("(a :: Str) -> Int"));
}
}