use std::collections::HashSet;
use std::path::Path;
use std::time::{Duration, Instant};
use aristo_core::canon::CanonMatchesFile;
use aristo_core::canon_verify::{
AnnotationOutcomeStatus, GetVerifySessionResponse, HttpVerifyClient, PostVerifySessionResponse,
SessionStatus, TestOutcomeStatus, VerifyClient, VerifyError, VerifySessionRequest,
VerifySessionTag,
};
use aristo_core::index::{AnnotationId, IndexEntry, IndexFile, IntentEntry};
use crate::{CliError, CliResult};
const LONGPOLL_WAIT_SECS: u32 = 30;
const POLL_INTERVAL: Duration = Duration::from_secs(3);
fn poll_interval() -> Duration {
if let Ok(ms) = std::env::var("ARISTO_VERIFY_POLL_MS") {
if let Ok(n) = ms.parse::<u64>() {
return Duration::from_millis(n);
}
}
POLL_INTERVAL
}
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CanonDispatchEntry<'a> {
pub id: &'a AnnotationId,
pub entry: &'a IntentEntry,
}
pub(crate) fn partition_full<'a>(
index: &'a IndexFile,
pending_full_ids: &[&'a AnnotationId],
) -> (
Vec<CanonDispatchEntry<'a>>,
Vec<&'a AnnotationId>, // non-canon-bound; keep the NotImplemented hint
) {
let mut canon: Vec<CanonDispatchEntry<'a>> = Vec::new();
let mut other: Vec<&'a AnnotationId> = Vec::new();
for id in pending_full_ids {
let Some(entry) = index.entries.get(*id) else {
continue;
};
let IndexEntry::Intent(intent) = entry else {
continue;
};
if id.is_canon_bound() {
canon.push(CanonDispatchEntry { id, entry: intent });
} else {
other.push(id);
}
}
(canon, other)
}
pub(crate) fn build_tags(
entries: &[CanonDispatchEntry<'_>],
matches: &CanonMatchesFile,
) -> Vec<VerifySessionTag> {
entries
.iter()
.filter_map(|d| build_one_tag(d, matches))
.collect()
}
fn build_one_tag(
dispatch: &CanonDispatchEntry<'_>,
matches: &CanonMatchesFile,
) -> Option<VerifySessionTag> {
let annotation_id = match &dispatch.entry.binding {
aristo_core::index::BindingState::Local => return None,
aristo_core::index::BindingState::Bound { linked } => linked.as_str().to_string(),
aristo_core::index::BindingState::Certified { linked, .. } => linked.as_str().to_string(),
};
let canon_id = strip_canon_prefix(dispatch.id.as_str()).to_string();
let cache_entry = matches.entries.get(dispatch.id)?;
let accepted = cache_entry.accepted_matches.first()?;
let version = accepted.version.clone();
let source_path = format_source_path(&dispatch.entry.file, dispatch.entry.site.as_str());
Some(VerifySessionTag {
annotation_id,
canon_id,
version,
source_path,
})
}
fn strip_canon_prefix(id: &str) -> &str {
id.strip_prefix("aristos:")
.or_else(|| id.strip_prefix("kanon:"))
.unwrap_or(id)
}
fn format_source_path(file: &str, site: &str) -> String {
match extract_line(site) {
Some(line) => format!("{file}:{line}"),
None => file.to_string(),
}
}
fn extract_line(site: &str) -> Option<u32> {
let after = site.split(" (line ").nth(1)?;
let trimmed = after.trim_end_matches(')');
trimmed.parse().ok()
}
#[cfg(test)]
pub(crate) fn dispatch_session<C: VerifyClient + ?Sized>(
client: &C,
req: &VerifySessionRequest,
) -> Result<PostVerifySessionResponse, VerifyError> {
client.post_session(req)
}
pub(crate) fn run_canon_dispatch(
workspace_root: &Path,
matches_path: &Path,
canon_entries: &[CanonDispatchEntry<'_>],
tags_filter: Option<&[String]>,
wait: bool,
) -> CliResult<usize> {
if canon_entries.is_empty() {
return Ok(0);
}
let creds = aristo_core::auth::resolve_full().map_err(no_auth_to_cli_error)?;
let matches = CanonMatchesFile::read(matches_path).map_err(|e| CliError::Other {
message: format!(
"failed to read canon-matches cache at {}: {e}",
matches_path.display()
),
exit_code: 1,
})?;
let mut tags = build_tags(canon_entries, &matches);
if let Some(requested) = tags_filter {
let allowed = build_tag_filter_set(canon_entries, requested)?;
tags.retain(|t| allowed.contains(&t.annotation_id));
if tags.is_empty() {
return Err(CliError::Other {
message: format!(
"--tags filter matched zero eligible canon-bound entries. \
Requested ids: {}",
requested.join(", ")
),
exit_code: 1,
});
}
}
if tags.is_empty() {
println!(
"\n→ {} canon-bound entries are pending verification, but no `accepted_matches` were found in",
canon_entries.len()
);
println!(
" .aristo/canon-matches.toml. Run `aristo canon refresh` to repopulate the cache."
);
return Ok(0);
}
let repo_full_name = match aristo_core::auth::derive_repo_full_name(workspace_root) {
Ok(r) => r,
Err(e) => {
std::env::var("ARISTO_REPO").map_err(|_| CliError::Other {
message: format!(
"could not determine repo for verify: {e}\n \
Set ARISTO_REPO=<owner/repo> to override (CI use)."
),
exit_code: 1,
})?
}
};
let commit_sha =
aristo_core::git::rev_parse_head(workspace_root).map_err(|e| CliError::Other {
message: format!("git rev-parse HEAD failed: {e}"),
exit_code: 1,
})?;
let pushed =
aristo_core::git::commit_present_on_remote(workspace_root, &commit_sha).map_err(|e| {
CliError::Other {
message: format!("git push-first precheck failed: {e}"),
exit_code: 1,
}
})?;
if !pushed {
return Err(CliError::Other {
message: format!(
"HEAD ({}) is not pushed to origin. Push your branch first; \
`aristo verify --watch` for local-edit sync is planned but \
not yet shipped.",
short_sha(&commit_sha)
),
exit_code: 1,
});
}
let base_url =
std::env::var("ARETTA_API_URL").unwrap_or_else(|_| creds.server.as_str().to_string());
let client: Box<dyn VerifyClient> = if let Some(mock) = test_mock_client_from_env() {
mock
} else {
Box::new(HttpVerifyClient::new(base_url, &creds.token))
};
let req = VerifySessionRequest {
repo_full_name,
commit_sha,
tags,
};
let resp = client.post_session(&req).map_err(verify_error_to_cli)?;
let dispatched = req.tags.len();
print_session_dispatched(&req, &resp);
if wait {
let final_snapshot = poll_until_terminal(&*client, &resp.session_id)?;
render_final_snapshot(&final_snapshot);
if !final_snapshot.summary.is_success() {
return Err(CliError::Other {
message: format!(
"verify reported {} failed, {} build_failed, {} inconclusive",
final_snapshot.summary.failed,
final_snapshot.summary.build_failed,
final_snapshot.summary.inconclusive
),
exit_code: 1,
});
}
}
Ok(dispatched)
}
pub(crate) fn run_view_session(session_id: &str, wait: bool) -> CliResult<()> {
let creds = aristo_core::auth::resolve_full().map_err(no_auth_to_cli_error)?;
let base_url =
std::env::var("ARETTA_API_URL").unwrap_or_else(|_| creds.server.as_str().to_string());
let client: Box<dyn VerifyClient> = if let Some(mock) = test_mock_client_from_env() {
mock
} else {
Box::new(HttpVerifyClient::new(base_url, &creds.token))
};
let snapshot = if wait {
poll_until_terminal(&*client, session_id)?
} else {
client
.get_session(session_id, None)
.map_err(verify_error_to_cli)?
};
render_final_snapshot(&snapshot);
if wait && !snapshot.summary.is_success() {
return Err(CliError::Other {
message: format!(
"verify reported {} failed, {} build_failed, {} inconclusive",
snapshot.summary.failed,
snapshot.summary.build_failed,
snapshot.summary.inconclusive
),
exit_code: 1,
});
}
Ok(())
}
fn poll_until_terminal<C: VerifyClient + ?Sized>(
client: &C,
session_id: &str,
) -> CliResult<GetVerifySessionResponse> {
let started = Instant::now();
let mut last_heartbeat = started;
let mut intermediate_rendered = false;
loop {
let snapshot = client
.get_session(session_id, Some(LONGPOLL_WAIT_SECS))
.map_err(verify_error_to_cli)?;
if snapshot.status.is_terminal() {
return Ok(snapshot);
}
if !intermediate_rendered {
println!();
println!(
" status: {} ({} of {} annotations verified so far)",
status_label(snapshot.status),
snapshot.summary.verified,
snapshot.summary.total_annotations
);
intermediate_rendered = true;
}
if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL {
let elapsed = started.elapsed().as_secs();
println!(" still running… ({elapsed}s elapsed)");
last_heartbeat = Instant::now();
}
std::thread::sleep(poll_interval());
}
}
fn build_tag_filter_set(
canon_entries: &[CanonDispatchEntry<'_>],
requested: &[String],
) -> CliResult<HashSet<String>> {
let mut allowed: HashSet<String> = HashSet::new();
let mut by_source: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
let mut by_canon: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
for d in canon_entries {
let linked = match &d.entry.binding {
aristo_core::index::BindingState::Bound { linked } => linked.as_str().to_string(),
aristo_core::index::BindingState::Certified { linked, .. } => {
linked.as_str().to_string()
}
aristo_core::index::BindingState::Local => continue,
};
by_source.insert(d.id.as_str(), linked.clone());
let canon = strip_canon_prefix(d.id.as_str());
by_canon.insert(canon, linked);
}
for raw in requested {
let id = raw.trim();
if id.is_empty() {
continue;
}
if id.starts_with("arta_") {
return Err(CliError::Other {
message: format!(
"--tags rejects opaque server ids (got `{id}`). Pass the source-form id \
instead, e.g. `--tags aristos:foo,kanon:bar`."
),
exit_code: 2,
});
}
if let Some(linked) = by_source.get(id) {
allowed.insert(linked.clone());
} else if let Some(linked) = by_canon.get(id) {
allowed.insert(linked.clone());
} else {
return Err(CliError::Other {
message: format!(
"--tags id `{id}` is not a canon-bound entry in this workspace's index"
),
exit_code: 1,
});
}
}
Ok(allowed)
}
fn render_final_snapshot(snapshot: &GetVerifySessionResponse) {
println!();
println!(
"session {} — verifying {} against canon {}",
snapshot.session_id,
short_sha(&snapshot.user_commit_sha),
snapshot.canon_version
);
println!(
"status: {} ({}/{} verified)",
status_label(snapshot.status),
snapshot.summary.verified,
snapshot.summary.total_annotations
);
println!();
println!("{:<55} {:<14} TESTS", "ANNOTATION", "STATUS");
for ann in &snapshot.annotations {
let icon = annotation_icon(ann.status);
let passed = ann
.tests
.iter()
.filter(|t| matches!(t.status, TestOutcomeStatus::Pass))
.count();
let header = format!(
"{}{}@{} ({})",
ann.tier, ann.canon_id, ann.version, ann.scope
);
let tests_summary = if ann.tests.is_empty() {
"(no coverage)".to_string()
} else {
format!("{}/{} passed", passed, ann.tests.len())
};
println!(
"{:<55} {} {:<11} {}",
header,
icon,
annotation_status_word(ann.status),
tests_summary
);
println!(" {}", ann.source_path);
for t in &ann.tests {
if !matches!(
t.status,
TestOutcomeStatus::Fail
| TestOutcomeStatus::BuildFailed
| TestOutcomeStatus::CloneFailed
| TestOutcomeStatus::Timeout
| TestOutcomeStatus::Error
) {
continue;
}
let bin = t.test_binary.as_deref().unwrap_or("(session)");
let word = test_status_word(t.status);
match &t.stderr_url {
Some(url) => println!(" • {bin} {word} — see {url}"),
None => println!(" • {bin} {word}"),
}
}
}
println!();
}
fn status_label(s: SessionStatus) -> &'static str {
match s {
SessionStatus::Queued => "queued",
SessionStatus::Running => "running",
SessionStatus::Done => "done",
SessionStatus::Failed => "failed",
SessionStatus::TimedOut => "timed out",
SessionStatus::Cancelled => "cancelled",
}
}
fn annotation_icon(s: AnnotationOutcomeStatus) -> &'static str {
match s {
AnnotationOutcomeStatus::Verified => "[ok]",
AnnotationOutcomeStatus::Failed => "[fail]",
AnnotationOutcomeStatus::BuildFailed => "[warn]",
AnnotationOutcomeStatus::Inconclusive => "[?]",
AnnotationOutcomeStatus::NoCoverage => "[--]",
}
}
fn annotation_status_word(s: AnnotationOutcomeStatus) -> &'static str {
match s {
AnnotationOutcomeStatus::Verified => "verified",
AnnotationOutcomeStatus::Failed => "failed",
AnnotationOutcomeStatus::BuildFailed => "build_failed",
AnnotationOutcomeStatus::Inconclusive => "inconclusive",
AnnotationOutcomeStatus::NoCoverage => "no_coverage",
}
}
fn test_status_word(s: TestOutcomeStatus) -> &'static str {
match s {
TestOutcomeStatus::Pass => "passed",
TestOutcomeStatus::Fail => "failed",
TestOutcomeStatus::BuildFailed => "build_failed",
TestOutcomeStatus::CloneFailed => "clone_failed",
TestOutcomeStatus::Timeout => "timeout",
TestOutcomeStatus::Error => "error",
}
}
fn print_session_dispatched(req: &VerifySessionRequest, resp: &PostVerifySessionResponse) {
let annotation_word = if resp.plan_size == 1 {
"annotation"
} else {
"annotations"
};
println!();
println!(
"→ verify session dispatched — verifying {} {annotation_word} against {}",
resp.plan_size,
short_sha(&req.commit_sha)
);
println!(" session: {}", resp.session_id);
println!(" view: {}", resp.view_url);
println!();
println!(
" Re-attach with: aristo verify --view {} --wait",
resp.session_id
);
}
fn short_sha(sha: &str) -> String {
sha.chars().take(7).collect()
}
fn no_auth_to_cli_error(e: aristo_core::auth::AuthError) -> CliError {
CliError::Other {
message: format!(
"verify requires authentication: {e}\n \
Run `aristo auth login` to sign in.\n \
(Without a token, the SDK skips canon-bound `verify=\"full\"` \
entries — they'd be rejected server-side anyway.)"
),
exit_code: 1,
}
}
fn verify_error_to_cli(e: VerifyError) -> CliError {
match e {
VerifyError::Auth(inner) => CliError::Other {
message: format!(
"verify auth error: {inner}\n \
Your token may be expired — re-run `aristo auth login`."
),
exit_code: 1,
},
VerifyError::BadRequest {
status: 402,
message,
} => CliError::Other {
message: format!(
"no canon coverage applies for your scopes — \
contact Aretta for DP onboarding to enable verification.\n \
(server message: {message})"
),
exit_code: 1,
},
VerifyError::BadRequest { status, message } => CliError::Other {
message: format!("verify server rejected request (HTTP {status}): {message}"),
exit_code: 1,
},
other => CliError::Other {
message: format!("verify failed: {other}"),
exit_code: 1,
},
}
}
fn test_mock_client_from_env() -> Option<Box<dyn VerifyClient>> {
let path = std::env::var("ARISTO_CANON_VERIFY_FIXTURE").ok()?;
let raw = std::fs::read_to_string(&path).ok()?;
let parsed: FixtureFile = serde_json::from_str(&raw).ok()?;
let post_resp = parsed.post.map(|p| PostVerifySessionResponse {
session_id: p.session_id,
view_url: p.view_url,
plan_size: p.plan_size,
});
let get_responses: Vec<GetVerifySessionResponse> = parsed.gets.unwrap_or_default();
let record_path = format!("{path}.posted.json");
let mock = match (post_resp, get_responses.is_empty()) {
(Some(p), true) => aristo_core::canon_verify::MockVerifyClient::with_post_response(p),
(Some(p), false) => {
aristo_core::canon_verify::MockVerifyClient::with_post_and_gets(p, get_responses)
}
(None, false) => {
aristo_core::canon_verify::MockVerifyClient::with_get_responses(get_responses)
}
(None, true) => return None,
};
Some(Box::new(RecordingMock {
inner: mock,
record_path,
}))
}
#[derive(serde::Deserialize)]
struct FixtureFile {
post: Option<FixturePost>,
gets: Option<Vec<GetVerifySessionResponse>>,
}
#[derive(serde::Deserialize)]
struct FixturePost {
session_id: String,
view_url: String,
plan_size: u32,
}
struct RecordingMock {
inner: aristo_core::canon_verify::MockVerifyClient,
record_path: String,
}
impl VerifyClient for RecordingMock {
fn post_session(
&self,
req: &VerifySessionRequest,
) -> Result<PostVerifySessionResponse, VerifyError> {
if let Ok(serialized) = serde_json::to_string_pretty(req) {
let _ = std::fs::write(&self.record_path, serialized);
}
self.inner.post_session(req)
}
fn get_session(
&self,
session_id: &str,
wait_seconds: Option<u32>,
) -> Result<aristo_core::canon_verify::GetVerifySessionResponse, VerifyError> {
self.inner.get_session(session_id, wait_seconds)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aristo_core::canon::{AcceptedMatch, CacheEntry, CacheMeta, PrefixTier};
use aristo_core::index::{
ArtaId, BindingState, CoveredRegion, IndexEntry, IntentEntry, Meta, Sha256, Status,
VerifyLevel, VerifyMethod,
};
use std::collections::BTreeMap;
fn empty_index() -> IndexFile {
IndexFile {
meta: Meta {
schema_version: 1,
generated_by: None,
generated_at: None,
source_root: None,
},
entries: BTreeMap::new(),
}
}
fn zero_hash() -> Sha256 {
Sha256::parse(&format!("sha256:{}", "0".repeat(64))).unwrap()
}
fn arta(s: &str) -> ArtaId {
ArtaId::parse(s).unwrap()
}
fn intent(
id: &str,
binding: BindingState,
file: &str,
site_with_line: &str,
) -> (AnnotationId, IntentEntry) {
(
AnnotationId::parse(id).unwrap(),
IntentEntry {
text: "the property".into(),
verify: VerifyLevel::Method(VerifyMethod::Full),
status: Status::Unknown,
text_hash: zero_hash(),
body_hash: zero_hash(),
file: file.into(),
site: site_with_line.into(),
covered_region: CoveredRegion::Function,
binding,
parent: None,
last_critiqued_at_text_hash: None,
last_critique_finding_count: None,
},
)
}
fn matches_with(id: &AnnotationId, canon_id: &str, version: &str) -> CanonMatchesFile {
let mut f = CanonMatchesFile {
meta: CacheMeta::default(),
..CanonMatchesFile::default()
};
f.entries.insert(
id.clone(),
CacheEntry {
last_match_text_hash: "x".into(),
canon_fetched_at: "2026-05-24T00:00:00Z".into(),
pending_matches: vec![],
accepted_matches: vec![AcceptedMatch {
canon_id: canon_id.into(),
version: version.into(),
canonical_text: "the property".into(),
canon_version: "v0.2.0".into(),
confidence: 0.95,
prefix_tier: PrefixTier::Aristos,
backed_by: Some("test backing".into()),
linked: None,
accepted_at: "2026-05-24T00:00:00Z".into(),
bound_at: "2026-05-24T00:00:00Z".into(),
}],
rejected_matches: vec![],
},
);
f
}
#[test]
fn partition_separates_canon_bound_from_local() {
let (aristos_id, aristos_entry) = intent(
"aristos:foo",
BindingState::Bound {
linked: arta("arta_op4q3z9NbV"),
},
"src/x.rs",
"fn x (line 1)",
);
let (kanon_id, kanon_entry) = intent(
"kanon:bar",
BindingState::Bound {
linked: arta("arta_xyz1234567"),
},
"src/y.rs",
"fn y (line 10)",
);
let (local_id, local_entry) =
intent("baz", BindingState::Local, "src/z.rs", "fn z (line 5)");
let mut index = empty_index();
index
.entries
.insert(aristos_id.clone(), IndexEntry::Intent(aristos_entry));
index
.entries
.insert(kanon_id.clone(), IndexEntry::Intent(kanon_entry));
index
.entries
.insert(local_id.clone(), IndexEntry::Intent(local_entry));
let pending = vec![&aristos_id, &kanon_id, &local_id];
let (canon, other) = partition_full(&index, &pending);
assert_eq!(canon.len(), 2);
assert_eq!(other.len(), 1);
assert_eq!(other[0].as_str(), "baz");
let canon_ids: Vec<_> = canon.iter().map(|c| c.id.as_str()).collect();
assert!(canon_ids.contains(&"aristos:foo"));
assert!(canon_ids.contains(&"kanon:bar"));
}
#[test]
fn partition_returns_empty_for_no_input() {
let index = empty_index();
let (canon, other) = partition_full(&index, &[]);
assert!(canon.is_empty());
assert!(other.is_empty());
}
#[test]
fn build_tags_emits_arta_id_canon_id_version_and_source_path() {
let (id, entry) = intent(
"aristos:vacuum_preserves_logical_content",
BindingState::Bound {
linked: arta("arta_op4q3z9NbV"),
},
"crates/vacuum/src/lib.rs",
"fn vacuum (line 42)",
);
let matches = matches_with(&id, "vacuum_preserves_logical_content", "v0.1.0");
let dispatch = vec![CanonDispatchEntry {
id: &id,
entry: &entry,
}];
let tags = build_tags(&dispatch, &matches);
assert_eq!(tags.len(), 1);
let t = &tags[0];
assert_eq!(t.annotation_id, "arta_op4q3z9NbV");
assert_eq!(t.canon_id, "vacuum_preserves_logical_content");
assert_eq!(t.version, "v0.1.0");
assert_eq!(t.source_path, "crates/vacuum/src/lib.rs:42");
}
#[test]
fn build_tags_works_for_certified_binding_state() {
use aristo_core::index::{CommitHash, VerifiedOutcome};
let (id, entry) = intent(
"kanon:foo",
BindingState::Certified {
linked: arta("arta_aaaa1234"),
verified_outcome: VerifiedOutcome::parse(&format!("v1:{}", "A".repeat(86)))
.unwrap(),
last_verified_at_commit: CommitHash::parse(&"a".repeat(40)).unwrap(),
},
"src/foo.rs",
"fn foo (line 7)",
);
let matches = matches_with(&id, "foo", "v0.2.0");
let dispatch = vec![CanonDispatchEntry {
id: &id,
entry: &entry,
}];
let tags = build_tags(&dispatch, &matches);
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].annotation_id, "arta_aaaa1234");
assert_eq!(tags[0].canon_id, "foo");
assert_eq!(tags[0].version, "v0.2.0");
}
#[test]
fn build_tags_drops_entries_missing_from_cache() {
let (id, entry) = intent(
"aristos:foo",
BindingState::Bound {
linked: arta("arta_aaaa1234"),
},
"src/foo.rs",
"fn foo (line 7)",
);
let matches = CanonMatchesFile::default();
let dispatch = vec![CanonDispatchEntry {
id: &id,
entry: &entry,
}];
let tags = build_tags(&dispatch, &matches);
assert!(tags.is_empty(), "missing cache entry must drop the tag");
}
#[test]
fn build_tags_drops_local_binding() {
let (id, entry) = intent(
"aristos:foo",
BindingState::Local,
"src/foo.rs",
"fn foo (line 7)",
);
let matches = matches_with(&id, "foo", "v0.1.0");
let dispatch = vec![CanonDispatchEntry {
id: &id,
entry: &entry,
}];
let tags = build_tags(&dispatch, &matches);
assert!(tags.is_empty());
}
#[test]
fn build_tags_falls_back_to_file_only_when_site_lacks_line() {
let (id, entry) = intent(
"aristos:foo",
BindingState::Bound {
linked: arta("arta_aaaa1234"),
},
"src/foo.rs",
"fn foo", );
let matches = matches_with(&id, "foo", "v0.1.0");
let dispatch = vec![CanonDispatchEntry {
id: &id,
entry: &entry,
}];
let tags = build_tags(&dispatch, &matches);
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].source_path, "src/foo.rs");
}
#[test]
fn strip_canon_prefix_handles_both_tiers_and_no_prefix() {
assert_eq!(strip_canon_prefix("aristos:foo_bar"), "foo_bar");
assert_eq!(
strip_canon_prefix("kanon:checkout_total_non_negative"),
"checkout_total_non_negative"
);
assert_eq!(strip_canon_prefix("local_id"), "local_id");
}
#[test]
fn extract_line_parses_walker_emitted_site_format() {
assert_eq!(extract_line("fn foo (line 42)"), Some(42));
assert_eq!(extract_line("fn really::long::path (line 1)"), Some(1));
assert_eq!(extract_line("fn x"), None);
assert_eq!(extract_line(""), None);
}
#[test]
fn dispatch_session_round_trips_through_mock() {
let mock = aristo_core::canon_verify::MockVerifyClient::with_post_response(
PostVerifySessionResponse {
session_id: "01HMTEST".into(),
view_url: "https://dev.aretta.ai/dashboard/jobs/01HMTEST".into(),
plan_size: 1,
},
);
let req = VerifySessionRequest {
repo_full_name: "owner/repo".into(),
commit_sha: "deadbeef".into(),
tags: vec![VerifySessionTag {
annotation_id: "arta_x".into(),
canon_id: "foo".into(),
version: "v0.1.0".into(),
source_path: "src/x.rs:1".into(),
}],
};
let resp = dispatch_session(&mock, &req).expect("dispatch ok");
assert_eq!(resp.session_id, "01HMTEST");
let posted = mock.posted_requests();
assert_eq!(posted.len(), 1);
assert_eq!(posted[0].tags[0].annotation_id, "arta_x");
}
#[test]
fn dispatch_session_propagates_server_error() {
let mock =
aristo_core::canon_verify::MockVerifyClient::with_post_error(VerifyError::BadRequest {
status: 402,
message: "no_canon_coverage".into(),
});
let req = VerifySessionRequest {
repo_full_name: "o/r".into(),
commit_sha: "x".into(),
tags: vec![],
};
let err = dispatch_session(&mock, &req).unwrap_err();
match err {
VerifyError::BadRequest { status: 402, .. } => {}
other => panic!("expected BadRequest 402, got {other:?}"),
}
}
}