use saya_cli::{
ClaimKindArg, Cli, Command, ContractsCommand, ForgetReasonArg, RenderFormat, ReviewDecisionArg,
RuntimeConfig, capture_output_start, capture_output_take, load_with_sources, profile_identity,
run_contracts,
};
use saya_store::{KnowledgeItemRequest, KnowledgeItemStore, SchemaStore, SqliteStateStore};
use saya_types::{
ClaimId, ClaimOrigin, ClaimPayload, Column, ColumnRequirement, Database, DatabaseObjectKind,
DatabaseObjectRef, FINGERPRINT_VERSION, KnowledgeSlot, KnowledgeState, ProfileIdentity, Schema,
SchemaBinding, SchemaFingerprint, SchemaTree, Table,
};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
fn temp_root(label: &str) -> PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!(
"saya-contracts-cli-{label}-{}-{stamp}",
std::process::id()
));
fs::create_dir_all(&root).unwrap();
root
}
fn runtime_at(root: &Path) -> (RuntimeConfig, PathBuf, String) {
let database = root.join("data.sqlite3");
fs::write(&database, b"").unwrap();
let connections = root.join("connections.toml");
fs::write(
&connections,
format!(
"[profiles.local]\ntype = 'sqlite'\npath = '{}'\n",
database.display()
),
)
.unwrap();
let options = saya_cli::GlobalOptions {
connections: Some(connections.clone()),
..Default::default()
};
let runtime = load_with_sources(&options, root, root, BTreeMap::new()).unwrap();
(runtime, connections, "local".to_string())
}
async fn store_at(root: &Path) -> SqliteStateStore {
let store = SqliteStateStore::new(root.join("state.sqlite3"));
store
.upsert_schema(
&identity_for(&runtime_for_scope(root), "local"),
&SchemaTree::default(),
)
.await
.unwrap();
store
}
fn identity_for(runtime: &RuntimeConfig, name: &str) -> String {
let profile = runtime.named_profile(name).unwrap();
profile_identity(name, profile, &runtime.cache_scope)
.as_str()
.to_owned()
}
fn runtime_for_scope(root: &Path) -> RuntimeConfig {
let connections = root.join("connections.toml");
let options = saya_cli::GlobalOptions {
connections: Some(connections),
..Default::default()
};
load_with_sources(&options, root, root, BTreeMap::new()).unwrap()
}
async fn run(
command: ContractsCommand,
runtime: &RuntimeConfig,
store: &SqliteStateStore,
format: RenderFormat,
) -> (i32, String, String) {
capture_output_start();
let code = run_contracts(command, runtime, format, store)
.await
.unwrap();
let (out, err) = capture_output_take();
(code, out, err)
}
fn json_events(stdout: &str) -> Vec<serde_json::Value> {
stdout
.lines()
.filter(|line| !line.is_empty())
.map(|line| serde_json::from_str(line).expect("line is JSON"))
.collect()
}
fn qualified() -> &'static str {
"analytics.public.orders"
}
fn alias_payload() -> ClaimPayload {
ClaimPayload::table_alias("customers").unwrap()
}
async fn seed_candidate(store: &SqliteStateStore, runtime: &RuntimeConfig, table: &str) -> ClaimId {
let identity = identity_for(runtime, "local");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
table,
DatabaseObjectKind::Table,
)
.unwrap();
let payload = alias_payload();
seed_pending_item(store, &object, &payload, ClaimOrigin::AssistantInferred).await
}
fn unobserved_fingerprint() -> SchemaFingerprint {
SchemaFingerprint::from_parts(saya_types::FINGERPRINT_VERSION, &"0".repeat(64)).unwrap()
}
fn slot_for(payload: &ClaimPayload) -> KnowledgeSlot {
match payload {
ClaimPayload::TableAlias { .. } => KnowledgeSlot::TableAlias,
_ => panic!("candidate seed only handles TableAlias, got {payload:?}"),
}
}
async fn seed_pending_item(
store: &SqliteStateStore,
object: &DatabaseObjectRef,
payload: &ClaimPayload,
source: ClaimOrigin,
) -> ClaimId {
let slot = slot_for(payload);
let binding = SchemaBinding::derive(&slot, payload).expect("slot/payload agree");
store
.put_knowledge_item(KnowledgeItemRequest {
object: object.clone(),
slot: slot.clone(),
value: payload.clone(),
source,
state: KnowledgeState::Pending,
schema_binding_json: serde_json::to_string(&binding).unwrap(),
fingerprint: unobserved_fingerprint(),
})
.await
.unwrap();
let id = store
.knowledge_for_object(object)
.await
.expect("knowledge items listed")
.into_iter()
.find(|i| i.slot == slot)
.expect("seeded item present")
.id;
ClaimId::parse(&id).expect("ki id parses")
}
#[tokio::test]
async fn remember_then_show_round_trips_claim_fields() {
let root = temp_root("round_trip");
let (runtime, _connections, _name) = runtime_at(&root);
let store = store_at(&root).await;
let remember = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::Alias,
value: "customers".into(),
column: None,
reason: None,
profile: None,
};
let (code, out, err) = run(remember, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
assert!(out.contains("remembered"), "out: {out}");
assert!(out.contains("confirmed"), "out: {out}");
let show = ContractsCommand::Show {
table: qualified().into(),
profile: None,
};
let (code, out, err) = run(show, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
assert!(out.contains("customers"), "value missing: {out}");
assert!(out.contains("table_alias"), "kind missing: {out}");
assert!(out.contains("user_explicit"), "origin missing: {out}");
assert!(out.contains("confirmed"), "status missing: {out}");
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn remember_twice_reports_duplicate_with_same_id() {
let root = temp_root("duplicate");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let remember = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::Alias,
value: "customers".into(),
column: None,
reason: None,
profile: None,
};
let (code1, out1, err1) = run(remember.clone(), &runtime, &store, RenderFormat::Text).await;
assert_eq!(code1, 0, "stderr: {err1}");
assert!(
out1.contains("remembered alias customers for analytics.public.orders (confirmed)"),
"confirmation names fact and object in words: {out1}"
);
assert!(!out1.contains("ki-"), "must contain no raw id: {out1}");
let (code2, out2, err2) = run(remember, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code2, 0, "stderr: {err2}");
assert!(
out2.contains(
"duplicate of alias customers for analytics.public.orders — already exists (confirmed)"
),
"second must report duplicate naming fact and object: {out2}"
);
assert!(
!out2.contains("ki-"),
"duplicate must contain no raw id: {out2}"
);
let identity = identity_for(&runtime, "local");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let items = store.knowledge_for_object(&object).await.unwrap();
assert_eq!(
items.len(),
1,
"duplicate created a second knowledge item: {items:?}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn remember_again_with_a_new_reason_revises_the_reason() {
let root = temp_root("reason_revision");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let first = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::TimeColumn,
value: "return_date".into(),
column: None,
reason: Some("a rental only counts once it comes back".into()),
profile: None,
};
let (code1, out1, err1) = run(first, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code1, 0, "stderr: {err1}");
assert!(out1.contains("remembered"), "first remember: {out1}");
let second = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::TimeColumn,
value: "return_date".into(),
column: None,
reason: Some("rentals are counted on return for billing".into()),
profile: None,
};
let (code2, out2, err2) = run(second, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code2, 0, "stderr: {err2}");
assert!(
out2.contains("remembered"),
"a new reason revises the claim, not a duplicate: {out2}"
);
assert!(
!out2.contains("duplicate"),
"a new reason must not read as a duplicate: {out2}"
);
let identity = identity_for(&runtime, "local");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let items = store.knowledge_for_object(&object).await.unwrap();
assert_eq!(items.len(), 1, "revision wrote no new row: {items:?}");
assert!(
matches!(
&items[0].value,
saya_types::ClaimPayload::DefaultTimeColumn { column, reason, .. }
if column == "return_date"
&& reason.as_deref() == Some("rentals are counted on return for billing")
),
"the stored reason is the new one: {:?}",
items[0].value
);
let third = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::TimeColumn,
value: "return_date".into(),
column: None,
reason: Some("rentals are counted on return for billing".into()),
profile: None,
};
let (code3, out3, err3) = run(third, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code3, 0, "stderr: {err3}");
assert!(
out3.contains("duplicate"),
"identical re-remember is a duplicate: {out3}"
);
let fourth = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::TimeColumn,
value: "return_date".into(),
column: None,
reason: None,
profile: None,
};
let (code4, out4, err4) = run(fourth, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code4, 0, "stderr: {err4}");
assert!(
out4.contains("duplicate"),
"re-stating without a reason is a duplicate, not an erase: {out4}"
);
let items_again = store.knowledge_for_object(&object).await.unwrap();
assert!(matches!(
&items_again[0].value,
saya_types::ClaimPayload::DefaultTimeColumn { reason, .. }
if reason.as_deref() == Some("rentals are counted on return for billing")
));
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn forget_then_show_no_longer_lists_the_claim() {
let root = temp_root("forget");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let remember = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::Alias,
value: "customers".into(),
column: None,
reason: None,
profile: None,
};
let (code, out, err) = run(remember, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
assert!(
out.contains("remembered alias customers for analytics.public.orders (confirmed)"),
"names fact and object: {out}"
);
assert!(!out.contains("ki-"), "no raw id in remember output: {out}");
let identity = identity_for(&runtime, "local");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let items = store.knowledge_for_object(&object).await.unwrap();
let id = items[0].id.as_str();
let forget = ContractsCommand::Forget {
claim_id: id.into(),
reason: ForgetReasonArg::Incorrect,
};
let (code, out, err) = run(forget, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
assert!(out.contains("forgotten"), "out: {out}");
let show = ContractsCommand::Show {
table: qualified().into(),
profile: None,
};
let (code, out, err) = run(show, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
assert!(
!out.contains("customers"),
"forgotten claim still listed: {out}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn remember_after_forget_reports_duplicate_forgotten_not_success() {
let root = temp_root("reforget");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let remember = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::Alias,
value: "customers".into(),
column: None,
reason: None,
profile: None,
};
let (code, out, err) = run(remember.clone(), &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
assert!(out.contains("remembered"), "out: {out}");
assert!(!out.contains("ki-"), "no raw id: {out}");
let identity = identity_for(&runtime, "local");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let items = store.knowledge_for_object(&object).await.unwrap();
let id = items[0].id.as_str();
let forget = ContractsCommand::Forget {
claim_id: id.into(),
reason: ForgetReasonArg::Incorrect,
};
let (code, _out, err) = run(forget, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
let (code, out, err) = run(remember, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
assert!(out.contains("duplicate"), "out: {out}");
assert!(
out.contains("previously forgotten"),
"duplicate of a forgotten claim must say previously forgotten: {out}"
);
assert!(
out.contains("alias customers for analytics.public.orders"),
"names fact and object: {out}"
);
assert!(!out.contains("ki-"), "no raw id: {out}");
assert!(
!out.starts_with("remembered"),
"must not read as success: {out}"
);
let identity = identity_for(&runtime, "local");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let items = store.knowledge_for_object(&object).await.unwrap();
assert_eq!(
items.len(),
1,
"re-remember created a new knowledge item: {items:?}"
);
assert_eq!(
items[0].state,
KnowledgeState::Dismissed,
"the forgotten tombstone must stay dismissed: {items:?}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn decide_confirm_on_candidate_confirms_and_on_confirmed_conflicts() {
let root = temp_root("decide_confirm");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let identity = identity_for(&runtime, "local");
store
.upsert_schema(&identity, &orders_schema())
.await
.unwrap();
let candidate_id = seed_candidate(&store, &runtime, "orders").await;
let prefix = candidate_id.as_str().to_string();
let confirm = ContractsCommand::Decide {
prefix: prefix.clone(),
decision: ReviewDecisionArg::Confirm,
profile: None,
};
let (code, out, err) = run(confirm, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
assert!(out.contains("confirmed"), "candidate -> confirmed: {out}");
let (code, out, err) = run(
ContractsCommand::Decide {
prefix: prefix.clone(),
decision: ReviewDecisionArg::Confirm,
profile: None,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_eq!(code, 0, "re-confirm must succeed, not conflict: {out}{err}");
assert!(
out.contains("confirmed"),
"re-confirmed item stays Active/confirmed: {out}"
);
let dismissed_id = seed_candidate(&store, &runtime, "shipments").await;
let _ = run(
ContractsCommand::Forget {
claim_id: dismissed_id.as_str().into(),
reason: ForgetReasonArg::Incorrect,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
let (code, out, err) = run(
ContractsCommand::Decide {
prefix: dismissed_id.as_str().into(),
decision: ReviewDecisionArg::Confirm,
profile: None,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_ne!(
code, 0,
"confirming a dismissed item must not succeed: {out}{err}"
);
let combined = format!("{out}{err}");
assert!(
combined.contains("conflict"),
"expected a typed conflict for a dismissed item, got: {combined}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn unknown_profile_is_typed_error_listing_available_names() {
let root = temp_root("unknown_profile");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let list = ContractsCommand::List {
profile: Some("nope".into()),
};
let (code, out, err) = run(list, &runtime, &store, RenderFormat::Text).await;
assert_ne!(code, 0, "unknown profile must not succeed: {out}{err}");
let combined = format!("{out}{err}");
assert!(
combined.contains("nope"),
"error should name the unknown profile: {combined}"
);
assert!(
combined.contains("local"),
"error should list available profile names: {combined}"
);
let identity = identity_for(&runtime, "local");
assert!(
!combined.contains(&identity),
"opaque identity leaked into error: {combined}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn malformed_qualified_table_is_typed_error() {
let root = temp_root("malformed_table");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
for bad in ["orders", "public.orders", "a.b.c.d"] {
let show = ContractsCommand::Show {
table: bad.into(),
profile: None,
};
let (code, out, err) = run(show, &runtime, &store, RenderFormat::Text).await;
assert_ne!(code, 0, "{bad:?} must not succeed: {out}{err}");
let combined = format!("{out}{err}");
assert!(
combined.contains("catalog.schema.object"),
"error should name the expected form for {bad:?}: {combined}"
);
assert!(
!combined.contains(bad),
"error must not echo untrusted input {bad:?}: {combined}"
);
}
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn opaque_profile_identity_never_reaches_rendered_output() {
let root = temp_root("identity_leak");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let remember = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::Alias,
value: "customers".into(),
column: None,
reason: None,
profile: None,
};
let (code, _out, err) = run(remember, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
let identity = identity_for(&runtime, "local");
assert_eq!(identity.len(), 66);
assert!(identity.starts_with("p-"));
let list = ContractsCommand::List { profile: None };
let show = ContractsCommand::Show {
table: qualified().into(),
profile: None,
};
for format in [RenderFormat::Text, RenderFormat::Json, RenderFormat::Ndjson] {
let (code, list_out, list_err) = run(list.clone(), &runtime, &store, format).await;
assert_eq!(code, 0, "list {format:?} stderr: {list_err}");
let (code, show_out, show_err) = run(show.clone(), &runtime, &store, format).await;
assert_eq!(code, 0, "show {format:?} stderr: {show_err}");
for captured in [list_out, list_err, show_out, show_err] {
assert!(
!captured.contains(&identity),
"opaque identity leaked into {format:?} output: {captured}"
);
}
}
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn unopenable_store_reads_and_writes_both_exit_nonzero() {
let root = temp_root("unopenable");
fs::write(root.join("blocker"), b"x").unwrap();
let bad = root.join("blocker/state.sqlite3");
let store = SqliteStateStore::new(&bad);
let (runtime, _c, _n) = runtime_at(&root);
let list = ContractsCommand::List { profile: None };
let (code, out, err) = run(list, &runtime, &store, RenderFormat::Text).await;
assert_ne!(
code, 0,
"list must exit non-zero on an unopenable store: {out}{err}"
);
let combined = format!("{out}{err}");
assert!(
!combined.is_empty(),
"list must emit a diagnostic on an unopenable store"
);
let remember = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::Alias,
value: "customers".into(),
column: None,
reason: None,
profile: None,
};
let (code, _out, _err) = run(remember, &runtime, &store, RenderFormat::Text).await;
assert_ne!(
code, 0,
"a write against an unopenable store must exit non-zero"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn json_and_ndjson_carry_same_claim_ids_as_text() {
let root = temp_root("formats");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let remember = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::Alias,
value: "customers".into(),
column: None,
reason: None,
profile: None,
};
let (code, text_out, err) = run(remember.clone(), &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
assert!(
text_out.contains("remembered alias customers for analytics.public.orders"),
"text form names the fact and object: {text_out}"
);
assert!(
!text_out.contains("ki-"),
"text form drops raw id: {text_out}"
);
let identity = identity_for(&runtime, "local");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let items = store.knowledge_for_object(&object).await.unwrap();
let stored_id = items[0].id.as_str();
for format in [RenderFormat::Json, RenderFormat::Ndjson] {
let (code, out, err) = run(remember.clone(), &runtime, &store, format).await;
assert_eq!(code, 0, "{format:?} stderr: {err}");
let events = json_events(&out);
let remembered_evt = events
.iter()
.find(|v| v["event"] == "contract_remembered")
.expect("a contract_remembered event was emitted");
assert_eq!(remembered_evt["action"], "duplicate");
assert_eq!(
remembered_evt["status"], "confirmed",
"duplicate of a confirmed claim carries its status"
);
assert_eq!(remembered_evt["object"], "analytics.public.orders");
assert_eq!(remembered_evt["kind"], "alias");
assert_eq!(remembered_evt["value"], "customers");
assert_eq!(
remembered_evt["claim_id"], stored_id,
"{format:?} remember id disagrees with stored id"
);
}
let (code, remember_text, err) =
run(remember.clone(), &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
assert!(
!remember_text.contains(stored_id),
"text confirmation must not print the raw id: {remember_text}"
);
let show = ContractsCommand::Show {
table: qualified().into(),
profile: None,
};
for format in [RenderFormat::Json, RenderFormat::Ndjson] {
let (code, out, err) = run(show.clone(), &runtime, &store, format).await;
assert_eq!(code, 0, "{format:?} stderr: {err}");
let events = json_events(&out);
let shown = events
.iter()
.find(|v| v["event"] == "contract_show")
.expect("a contract_show event was emitted");
let claim_id = shown["contract"]["claims"][0]["claim_id"]
.as_str()
.expect("claim id present");
assert_eq!(
claim_id, stored_id,
"{format:?} show id disagrees with stored id"
);
}
let (code, show_text, err) = run(show, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "stderr: {err}");
assert!(show_text.contains("customers"), "text show: {show_text}");
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn queue_lists_candidates_and_decide_transitions_them() {
let root = temp_root("queue_decide");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let identity = identity_for(&runtime, "local");
store.invalidate_schema(&identity).await.unwrap();
let cand_id = seed_candidate(&store, &runtime, "orders").await;
let queue = ContractsCommand::Queue {
profile: None,
limit: None,
};
let (code, out, err) = run(queue, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "queue stderr: {err}");
assert!(
out.contains(cand_id.as_str()),
"queue must name the candidate's full id: {out}"
);
assert!(out.contains("candidate"), "queue must show status: {out}");
assert!(out.contains("table_alias"), "queue must show kind: {out}");
assert!(out.contains("customers"), "queue must show value: {out}");
assert!(
out.contains("analytics.public.orders"),
"queue must show object: {out}"
);
assert!(
out.contains("live_schema_unavailable"),
"queue must show schema state: {out}"
);
assert!(
out.contains("evidence 0"),
"queue must show evidence count: {out}"
);
let confirm = ContractsCommand::Decide {
prefix: cand_id.as_str().into(),
decision: ReviewDecisionArg::Confirm,
profile: None,
};
let (code, out, err) = run(confirm, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "confirm stderr: {err}");
assert!(out.contains("confirmed"), "confirm out: {out}");
let show = ContractsCommand::Show {
table: qualified().into(),
profile: None,
};
let (code, out, err) = run(show, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "show stderr: {err}");
assert!(
out.contains("customers"),
"confirmed candidate is now recallable: {out}"
);
let queue = ContractsCommand::Queue {
profile: None,
limit: None,
};
let (code, out, err) = run(queue, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "queue stderr: {err}");
assert!(
!out.contains(cand_id.as_str()),
"confirmed candidate must leave the queue: {out}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn queue_rejected_candidate_never_becomes_recallable() {
let root = temp_root("queue_reject");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let cand_id = seed_candidate(&store, &runtime, "orders").await;
let reject = ContractsCommand::Decide {
prefix: cand_id.as_str().into(),
decision: ReviewDecisionArg::Reject,
profile: None,
};
let (code, out, err) = run(reject, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "reject stderr: {err}");
assert!(out.contains("rejected"), "reject out: {out}");
let queue = ContractsCommand::Queue {
profile: None,
limit: None,
};
let (code, out, err) = run(queue, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "queue stderr: {err}");
assert!(
!out.contains(cand_id.as_str()),
"rejected candidate must leave the queue: {out}"
);
let show = ContractsCommand::Show {
table: qualified().into(),
profile: None,
};
let (code, out, err) = run(show, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "show stderr: {err}");
assert!(
out.contains("No contract"),
"rejected candidate must never be recallable: {out}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn queue_never_leaks_the_opaque_profile_identity() {
let root = temp_root("queue_identity");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let _id = seed_candidate(&store, &runtime, "orders").await;
let identity = identity_for(&runtime, "local");
let queue = ContractsCommand::Queue {
profile: None,
limit: None,
};
for format in [RenderFormat::Text, RenderFormat::Json, RenderFormat::Ndjson] {
let (code, out, err) = run(queue.clone(), &runtime, &store, format).await;
assert_eq!(code, 0, "queue {format:?} stderr: {err}");
for captured in [out.as_str(), err.as_str()] {
assert!(
!captured.contains(&identity),
"opaque identity leaked into queue {format:?}: {captured}"
);
}
}
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn queue_unopenable_store_exits_nonzero_like_list() {
let root = temp_root("queue_unopenable");
fs::write(root.join("blocker"), b"x").unwrap();
let bad = root.join("blocker/state.sqlite3");
let store = SqliteStateStore::new(&bad);
let (runtime, _c, _n) = runtime_at(&root);
let queue = ContractsCommand::Queue {
profile: None,
limit: None,
};
let (qcode, qout, qerr) = run(queue, &runtime, &store, RenderFormat::Text).await;
assert_ne!(
qcode, 0,
"queue must exit non-zero on an unopenable store: {qout}{qerr}"
);
assert!(
!format!("{qout}{qerr}").is_empty(),
"queue must emit a diagnostic on an unopenable store"
);
let list = ContractsCommand::List { profile: None };
let (lcode, lout, lerr) = run(list, &runtime, &store, RenderFormat::Text).await;
assert_eq!(qcode, lcode, "queue exit diverged from list");
assert_eq!(qout, lout, "queue stdout diverged from list");
assert_eq!(qerr, lerr, "queue stderr diverged from list");
let _ = fs::remove_dir_all(root);
}
fn orders_table() -> Table {
Table {
name: "orders".into(),
columns: vec![Column {
name: "id".into(),
data_type: "bigint".into(),
nullable: false,
}],
}
}
fn orders_schema() -> SchemaTree {
SchemaTree {
databases: vec![Database {
name: "analytics".into(),
schemas: vec![Schema {
name: "public".into(),
tables: vec![orders_table()],
}],
}],
}
}
async fn seed_current_claim(store: &SqliteStateStore, runtime: &RuntimeConfig) {
let identity = identity_for(runtime, "local");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let payload = ClaimPayload::table_alias("orders").unwrap();
let slot = KnowledgeSlot::TableAlias;
let binding = SchemaBinding::derive(&slot, &payload).expect("slot/payload agree");
store
.put_knowledge_item(KnowledgeItemRequest {
object: object.clone(),
slot,
value: payload,
source: ClaimOrigin::UserExplicit,
state: KnowledgeState::Active,
schema_binding_json: serde_json::to_string(&binding).unwrap(),
fingerprint: SchemaFingerprint::from_parts(FINGERPRINT_VERSION, &"0".repeat(64))
.unwrap(),
})
.await
.unwrap();
}
#[tokio::test]
async fn list_and_show_report_current_against_a_cached_schema() {
let root = temp_root("list_show_cached_schema_state");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
seed_current_claim(&store, &runtime).await;
let identity = identity_for(&runtime, "local");
store
.upsert_schema(&identity, &orders_schema())
.await
.unwrap();
let list = ContractsCommand::List { profile: None };
let (code, out, err) = run(list, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "list stderr: {err}");
assert!(
out.contains("[current]"),
"list must report current against the cached schema: {out}"
);
assert!(
!out.contains("live_schema_unavailable"),
"list must not report live_schema_unavailable when a schema is cached: {out}"
);
let show = ContractsCommand::Show {
table: qualified().into(),
profile: None,
};
let (code, out, err) = run(show, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "show stderr: {err}");
assert!(
out.contains("[current]"),
"show must report current against the cached schema: {out}"
);
assert!(
!out.contains("live_schema_unavailable"),
"show must not report live_schema_unavailable when a schema is cached: {out}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn list_and_show_report_live_schema_unavailable_when_nothing_is_cached() {
let root = temp_root("list_show_no_cache");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let identity = identity_for(&runtime, "local");
store.invalidate_schema(&identity).await.unwrap();
seed_current_claim(&store, &runtime).await;
let list = ContractsCommand::List { profile: None };
let (code, out, err) = run(list, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "list stderr: {err}");
assert!(
out.contains("live_schema_unavailable"),
"list must report live_schema_unavailable when no schema is cached: {out}"
);
let show = ContractsCommand::Show {
table: qualified().into(),
profile: None,
};
let (code, out, err) = run(show, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "show stderr: {err}");
assert!(
out.contains("live_schema_unavailable"),
"show must report live_schema_unavailable when no schema is cached: {out}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn remember_against_cached_schema_reads_current_not_needs_review() {
let root = temp_root("remember_current_not_needs_review");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let identity = identity_for(&runtime, "local");
store
.upsert_schema(&identity, &orders_schema())
.await
.unwrap();
let remember = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::Alias,
value: "customers".into(),
column: None,
reason: None,
profile: None,
};
let (code, out, err) = run(remember, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "remember stderr: {err}");
assert!(out.contains("remembered"), "out: {out}");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let items = store.knowledge_for_object(&object).await.unwrap();
assert_eq!(items.len(), 1, "expected one item: {items:?}");
assert_eq!(
items[0].fingerprint_version, FINGERPRINT_VERSION,
"remember must store the item under the current fingerprint version"
);
let binding: SchemaBinding =
serde_json::from_str(&items[0].schema_binding_json).expect("binding deserializes");
assert!(
matches!(binding, SchemaBinding::Table),
"a table-alias stores a Table binding, got {binding:?}"
);
assert_eq!(items[0].state, KnowledgeState::Active);
let show = ContractsCommand::Show {
table: qualified().into(),
profile: None,
};
let (code, out, err) = run(show, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "show stderr: {err}");
assert!(
out.contains("[current]"),
"remember-then-show must read current against the cached schema: {out}"
);
assert!(
!out.contains("needs_review"),
"a claim remembered right after a refresh must not read needs_review: {out}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn remember_column_claim_against_cached_schema_snapshots_real_type() {
let root = temp_root("remember_column_snapshot_real_type");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let identity = identity_for(&runtime, "local");
let schema = SchemaTree {
databases: vec![Database {
name: "analytics".into(),
schemas: vec![Schema {
name: "public".into(),
tables: vec![Table {
name: "orders".into(),
columns: vec![Column {
name: "id".into(),
data_type: "bigint".into(),
nullable: false,
}],
}],
}],
}],
};
store.upsert_schema(&identity, &schema).await.unwrap();
let remember = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::ColumnRole,
value: "identifier".into(),
column: Some("id".into()),
reason: None,
profile: None,
};
let (code, out, err) = run(remember, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "remember stderr: {err}");
assert!(out.contains("remembered"), "out: {out}");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let items = store.knowledge_for_object(&object).await.unwrap();
assert_eq!(items.len(), 1, "{items:?}");
let binding: SchemaBinding =
serde_json::from_str(&items[0].schema_binding_json).expect("binding deserializes");
assert_eq!(
binding,
SchemaBinding::Column {
column: "id".into(),
requirement: ColumnRequirement::Exists,
},
"a column-role claim stores a Column binding naming the column: {binding:?}"
);
assert_eq!(items[0].state, KnowledgeState::Active);
let show = ContractsCommand::Show {
table: qualified().into(),
profile: None,
};
let (code, out, err) = run(show, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "show stderr: {err}");
assert!(out.contains("[current]"), "must read current: {out}");
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn remember_unknown_object_against_cached_schema_refuses_and_stores_nothing() {
let root = temp_root("remember_unknown_object_refuses");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let identity = identity_for(&runtime, "local");
store
.upsert_schema(&identity, &orders_schema())
.await
.unwrap();
let remember = ContractsCommand::Remember {
table: "analytics.public.ghost".into(),
kind: ClaimKindArg::Alias,
value: "nope".into(),
column: None,
reason: None,
profile: None,
};
let (code, out, err) = run(remember, &runtime, &store, RenderFormat::Text).await;
assert_ne!(code, 0, "remember of an unknown object must not succeed");
let combined = format!("{out}{err}");
assert!(
combined.contains("analytics.public.ghost"),
"error must name the unknown object so the user sees their typo: {combined}"
);
assert!(
combined.contains("connection schema"),
"error must suggest `connection schema --refresh`: {combined}"
);
assert!(
combined.contains("--refresh"),
"error must mention the --refresh flag: {combined}"
);
assert!(
!combined.contains(&identity),
"opaque identity leaked into the unknown-object error: {combined}"
);
let profile = ProfileIdentity::parse(&identity).unwrap();
let ghost = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"ghost",
DatabaseObjectKind::Table,
)
.unwrap();
let items = store.knowledge_for_object(&ghost).await.unwrap();
assert!(
items.is_empty(),
"refuse must store nothing for the unknown object: {items:?}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn remember_with_no_cached_schema_keeps_sentinel_and_succeeds() {
let root = temp_root("remember_no_cache_sentinel");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let identity = identity_for(&runtime, "local");
store.invalidate_schema(&identity).await.unwrap();
let remember = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::Alias,
value: "customers".into(),
column: None,
reason: None,
profile: None,
};
let (code, out, err) = run(remember, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "remember with no cache must still succeed: {err}");
assert!(out.contains("remembered"), "out: {out}");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let items = store.knowledge_for_object(&object).await.unwrap();
assert_eq!(items.len(), 1, "{items:?}");
assert_eq!(items[0].fingerprint_version, FINGERPRINT_VERSION);
assert_eq!(items[0].state, KnowledgeState::Active);
let show = ContractsCommand::Show {
table: qualified().into(),
profile: None,
};
let (code, out, err) = run(show, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "show stderr: {err}");
assert!(
out.contains("live_schema_unavailable"),
"no-cache claim must read live_schema_unavailable, not be refused or fabricated current: {out}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn remember_against_empty_cached_schema_keeps_sentinel_and_succeeds() {
let root = temp_root("remember_empty_cache_sentinel");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let remember = ContractsCommand::Remember {
table: qualified().into(),
kind: ClaimKindArg::Alias,
value: "customers".into(),
column: None,
reason: None,
profile: None,
};
let (code, out, err) = run(remember, &runtime, &store, RenderFormat::Text).await;
assert_eq!(
code, 0,
"remember against an empty cached schema must succeed, not refuse: {err}"
);
assert!(out.contains("remembered"), "out: {out}");
let _ = fs::remove_dir_all(root);
}
async fn seed_current_candidate(store: &SqliteStateStore, runtime: &RuntimeConfig) -> ClaimId {
let identity = identity_for(runtime, "local");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let payload = ClaimPayload::table_alias("orders").unwrap();
seed_pending_item(store, &object, &payload, ClaimOrigin::AssistantInferred).await
}
#[tokio::test]
async fn queue_reports_current_against_a_cached_schema() {
let root = temp_root("queue_cached_schema_state");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let identity = identity_for(&runtime, "local");
store
.upsert_schema(&identity, &orders_schema())
.await
.unwrap();
seed_current_candidate(&store, &runtime).await;
let queue = ContractsCommand::Queue {
profile: None,
limit: None,
};
let (code, out, err) = run(queue, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "queue stderr: {err}");
assert!(
out.contains("[current]"),
"queue must report current against the cached schema: {out}"
);
assert!(
!out.contains("live_schema_unavailable"),
"queue must not report live_schema_unavailable when a schema is cached: {out}"
);
let _ = fs::remove_dir_all(root);
}
async fn seed_drifted_active_claim(
store: &SqliteStateStore,
runtime: &RuntimeConfig,
table: &str,
) -> ClaimId {
let identity = identity_for(runtime, "local");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
table,
DatabaseObjectKind::Table,
)
.unwrap();
let payload = ClaimPayload::default_time_column("created_at", None).unwrap();
let slot = KnowledgeSlot::TableDefaultTime;
let binding = SchemaBinding::derive(&slot, &payload).expect("slot/payload agree");
store
.put_knowledge_item(KnowledgeItemRequest {
object: object.clone(),
slot,
value: payload,
source: ClaimOrigin::UserExplicit,
state: KnowledgeState::Active,
schema_binding_json: serde_json::to_string(&binding).unwrap(),
fingerprint: SchemaFingerprint::from_parts(FINGERPRINT_VERSION, &"0".repeat(64))
.unwrap(),
})
.await
.unwrap();
let drifted = SchemaTree {
databases: vec![Database {
name: "analytics".into(),
schemas: vec![Schema {
name: "public".into(),
tables: vec![Table {
name: table.into(),
columns: vec![Column {
name: "id".into(),
data_type: "bigint".into(),
nullable: false,
}],
}],
}],
}],
};
store.upsert_schema(&identity, &drifted).await.unwrap();
let id = store
.knowledge_for_object(&object)
.await
.expect("knowledge items listed")
.into_iter()
.find(|i| i.slot == KnowledgeSlot::TableDefaultTime)
.expect("drifted item stored")
.id;
ClaimId::parse(&id).expect("ki id parses")
}
#[tokio::test]
async fn queue_distinguishes_candidate_from_stale_in_output() {
let root = temp_root("queue_candidate_vs_stale");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let cand_id = seed_candidate(&store, &runtime, "orders").await;
let stale_id = seed_drifted_active_claim(&store, &runtime, "shipments").await;
let queue = ContractsCommand::Queue {
profile: None,
limit: None,
};
let (code, out, err) = run(queue.clone(), &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "queue stderr: {err}");
assert!(
out.contains(cand_id.as_str()),
"candidate id missing from queue: {out}"
);
assert!(
!out.contains(stale_id.as_str()),
"a broken confirmed fact must not appear in the candidate queue: {out}"
);
assert!(
out.contains("candidate"),
"queue must show the candidate's status: {out}"
);
let (code, out, err) = run(
ContractsCommand::Show {
table: "analytics.public.shipments".into(),
profile: None,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_eq!(code, 0, "show stderr: {err}");
assert!(
out.contains("created_at"),
"the broken fact is shown to a reviewer: {out}"
);
assert!(
out.contains("[stale]"),
"show reports the broken fact as stale, not current: {out}"
);
let (code, out, err) = run(queue, &runtime, &store, RenderFormat::Json).await;
assert_eq!(code, 0, "queue json stderr: {err}");
let events = json_events(&out);
let queue_event = events
.iter()
.find(|v| v["event"] == "contract_queue")
.expect("a contract_queue event was emitted");
let items = queue_event["items"].as_array().expect("items is an array");
let cand_item = items
.iter()
.find(|v| v["claim_id"] == cand_id.as_str())
.expect("candidate item present in JSON");
assert_eq!(
cand_item["status"], "candidate",
"JSON candidate status: {cand_item}"
);
assert!(
!items.iter().any(|v| v["claim_id"] == stale_id.as_str()),
"the broken confirmed fact must not be a queue item: {items:?}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn decide_accepts_a_full_length_claim_id_as_a_prefix_of_itself() {
let root = temp_root("decide_full_id_prefix");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let identity = identity_for(&runtime, "local");
store
.upsert_schema(&identity, &orders_schema())
.await
.unwrap();
let candidate_id = seed_candidate(&store, &runtime, "orders").await;
let full_id = candidate_id.as_str().to_string();
let decide = ContractsCommand::Decide {
prefix: full_id.clone(),
decision: ReviewDecisionArg::Confirm,
profile: None,
};
let (code, out, err) = run(decide, &runtime, &store, RenderFormat::Text).await;
assert_eq!(code, 0, "a full-length id must resolve via decide: {err}");
assert!(out.contains("confirmed"), "candidate -> confirmed: {out}");
let profile = ProfileIdentity::parse(&identity).unwrap();
let object = DatabaseObjectRef::new(
profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let items = store.knowledge_for_object(&object).await.unwrap();
assert_eq!(items.len(), 1, "decide wrote no new row: {items:?}");
assert_eq!(
items[0].state,
KnowledgeState::Active,
"the one matched claim is the one that was confirmed: {items:?}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn decide_profile_flag_reaches_a_claim_outside_the_active_profile() {
let root = temp_root("decide_cross_profile");
let local_db = root.join("local.sqlite3");
let staging_db = root.join("staging.sqlite3");
fs::write(&local_db, b"").unwrap();
fs::write(&staging_db, b"").unwrap();
let connections = root.join("connections.toml");
fs::write(
&connections,
format!(
"[profiles.local]\ntype = 'sqlite'\npath = '{}'\n\n\
[profiles.staging]\ntype = 'sqlite'\npath = '{}'\n",
local_db.display(),
staging_db.display(),
),
)
.unwrap();
let options = saya_cli::GlobalOptions {
connections: Some(connections),
profile: Some("local".into()),
..Default::default()
};
let runtime = load_with_sources(&options, &root, &root, BTreeMap::new()).unwrap();
let store = SqliteStateStore::new(root.join("state.sqlite3"));
let staging_identity = identity_for(&runtime, "staging");
store
.upsert_schema(&staging_identity, &orders_schema())
.await
.unwrap();
let staging_profile = ProfileIdentity::parse(&staging_identity).unwrap();
let object = DatabaseObjectRef::new(
staging_profile,
"analytics",
"public",
"orders",
DatabaseObjectKind::Table,
)
.unwrap();
let payload = alias_payload();
let slot = KnowledgeSlot::TableAlias;
let binding = SchemaBinding::derive(&slot, &payload).expect("slot/payload agree");
store
.put_knowledge_item(KnowledgeItemRequest {
object: object.clone(),
slot,
value: payload,
source: ClaimOrigin::AssistantInferred,
state: KnowledgeState::Pending,
schema_binding_json: serde_json::to_string(&binding).unwrap(),
fingerprint: unobserved_fingerprint(),
})
.await
.unwrap();
let staging_id = store
.knowledge_for_object(&object)
.await
.unwrap()
.into_iter()
.find(|i| i.slot == KnowledgeSlot::TableAlias)
.expect("staging candidate seeded")
.id;
let staging_id = ClaimId::parse(&staging_id).expect("ki id parses");
let prefix = staging_id.as_str().chars().take(6).collect::<String>();
let (default_code, default_out, default_err) = run(
ContractsCommand::Decide {
prefix: prefix.clone(),
decision: ReviewDecisionArg::Confirm,
profile: None,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_ne!(
default_code, 0,
"decide without --profile must not reach a non-active profile's claim: {default_out}{default_err}"
);
let default_combined = format!("{default_out}{default_err}");
assert!(
default_combined.contains("no claim matches"),
"default-profile refusal must say no match: {default_combined}"
);
let (code, out, err) = run(
ContractsCommand::Decide {
prefix,
decision: ReviewDecisionArg::Confirm,
profile: Some("staging".into()),
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_eq!(
code, 0,
"decide --profile staging must reach the staging claim: {err}"
);
assert!(
out.contains("confirmed"),
"staging candidate -> confirmed: {out}"
);
let items = store.knowledge_for_object(&object).await.unwrap();
assert_eq!(items.len(), 1, "no new row: {items:?}");
assert_eq!(
items[0].state,
KnowledgeState::Active,
"the staging claim was confirmed: {items:?}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn decide_refuses_an_ambiguous_prefix_at_runtime_not_parse_time() {
let root = temp_root("decide_ambiguous_prefix");
let (runtime, _c, _n) = runtime_at(&root);
let store = store_at(&root).await;
let _a = seed_candidate(&store, &runtime, "orders").await;
let _b = seed_candidate(&store, &runtime, "returns").await;
let (code, out, err) = run(
ContractsCommand::Decide {
prefix: "ki-".into(),
decision: ReviewDecisionArg::Confirm,
profile: None,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_ne!(code, 0, "ambiguous prefix must refuse: {out}{err}");
let combined = format!("{out}{err}");
assert!(
combined.contains("more than one claim"),
"ambiguous refusal names why: {combined}"
);
assert!(
!combined.contains("ki-"),
"refusal must not echo the prefix: {combined}"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn review_subcommand_no_longer_parses() {
use clap::Parser;
let parsed = Cli::try_parse_from([
"saya",
"contracts",
"review",
"ki-deadbeef",
"--confirm",
"--reject",
]);
assert!(
parsed.is_err(),
"`contracts review` must not parse after retirement: {parsed:?}"
);
let err = parsed.err().unwrap().to_string();
assert!(
!err.contains("choose exactly one"),
"the runtime AmbiguousReview message must not be reachable: {err}"
);
}
#[test]
fn decide_subcommand_parses_and_decision_is_a_single_value_enum() {
use clap::Parser;
let parsed = Cli::try_parse_from([
"saya",
"contracts",
"decide",
"ki-deadbeef",
"--decision",
"confirm",
]);
let cli = parsed.expect("`contracts decide` parses");
let command = cli.command.expect("a subcommand was given");
let Command::Contracts {
command: ContractsCommand::Decide { decision, .. },
} = command
else {
panic!("parsed to Decide, got {command:?}");
};
assert_eq!(decision, ReviewDecisionArg::Confirm);
let two = Cli::try_parse_from([
"saya",
"contracts",
"decide",
"ki-deadbeef",
"--decision",
"confirm",
"--decision",
"reject",
]);
assert!(
two.is_err(),
"two --decision values must not parse: {two:?}"
);
let unknown = Cli::try_parse_from([
"saya",
"contracts",
"decide",
"ki-deadbeef",
"--decision",
"maybe",
]);
assert!(
unknown.is_err(),
"an unknown --decision must not parse: {unknown:?}"
);
}
#[test]
fn contracts_help_advertises_decide_not_review() {
use clap::CommandFactory;
let help = Cli::command()
.find_subcommand("contracts")
.expect("contracts subcommand exists")
.clone()
.render_help()
.to_string();
assert!(
help.contains("decide"),
"contracts --help must advertise decide: {help}"
);
assert!(
!help
.lines()
.any(|line| line.trim_start().starts_with("review ")),
"contracts --help must not list a `review` subcommand: {help}"
);
}