use saya_cli::{
ContractsCommand, ForgetReasonArg, RenderFormat, RuntimeConfig, SlashCommand,
capture_output_start, capture_output_take, load_with_sources, parse_slash_command,
profile_identity, run_contracts,
};
use saya_store::{KnowledgeItemRequest, KnowledgeItemStore, SchemaStore, SqliteStateStore};
use saya_types::{
ClaimId, ClaimOrigin, ClaimPayload, DatabaseObjectKind, DatabaseObjectRef, KnowledgeSlot,
KnowledgeState, ProfileIdentity, SchemaBinding, SchemaFingerprint, SchemaTree,
};
use std::{
collections::BTreeMap, fs, path::Path, path::PathBuf, time::SystemTime, time::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-in-flow-{label}-{}-{stamp}",
std::process::id()
));
fs::create_dir_all(&root).unwrap();
root
}
fn runtime_at(root: &Path) -> (RuntimeConfig, 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),
..Default::default()
};
let runtime = load_with_sources(&options, root, root, BTreeMap::new()).unwrap();
(runtime, "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 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()
}
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()
}
async fn run_headless(
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)
}
async fn run_slash(
line: &str,
runtime: &RuntimeConfig,
store: &SqliteStateStore,
format: RenderFormat,
) -> (ContractsCommand, i32, String, String) {
let command = match parse_slash_command(line) {
Ok(Some(SlashCommand::Contracts(cmd))) => cmd,
other => panic!("expected SlashCommand::Contracts for {line:?}, got {other:?}"),
};
let (code, out, err) = run_headless(command.clone(), runtime, store, format).await;
(command, code, out, err)
}
fn qualified() -> &'static str {
"analytics.public.orders"
}
fn unobserved_fingerprint() -> SchemaFingerprint {
SchemaFingerprint::from_parts(saya_types::FINGERPRINT_VERSION, &"0".repeat(64)).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 = ClaimPayload::table_alias(table).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::AssistantInferred,
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 == KnowledgeSlot::TableAlias)
.expect("alias item stored")
.id;
ClaimId::parse(&id).expect("ki id parses")
}
fn short_prefix(id: &ClaimId) -> String {
id.as_str().chars().take(6).collect()
}
#[tokio::test]
async fn confirm_by_short_prefix_confirms_the_intended_claim() {
let root = temp_root("confirm_prefix");
let (runtime, _name) = runtime_at(&root);
let store = store_at(&root).await;
let id = seed_candidate(&store, &runtime, "orders").await;
let prefix = short_prefix(&id);
let (_cmd, code, out, err) = run_slash(
&format!("/confirm {prefix}"),
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_eq!(code, 0, "/confirm stderr: {err}");
assert!(out.contains("confirmed"), "/confirm out: {out}");
assert!(
out.contains(id.as_str()),
"must name the exact claim: {out}"
);
let (scode, sout, serr) = run_headless(
ContractsCommand::Show {
table: qualified().into(),
profile: None,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_eq!(scode, 0, "show stderr: {serr}");
assert!(sout.contains("confirmed"), "show after confirm: {sout}");
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn reject_by_short_prefix_rejects_and_promotes_nothing() {
let root = temp_root("reject_prefix");
let (runtime, _name) = runtime_at(&root);
let store = store_at(&root).await;
let id = seed_candidate(&store, &runtime, "orders").await;
let prefix = short_prefix(&id);
let (_cmd, code, out, err) = run_slash(
&format!("/reject {prefix}"),
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_eq!(code, 0, "/reject stderr: {err}");
assert!(out.contains("rejected"), "/reject out: {out}");
assert!(
out.contains(id.as_str()),
"must name the exact claim: {out}"
);
let (scode, sout, serr) = run_headless(
ContractsCommand::Show {
table: qualified().into(),
profile: None,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_eq!(scode, 0, "show stderr: {serr}");
assert!(
sout.contains("No contract"),
"rejected claim must not be recallable: {sout}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn ambiguous_or_unresolvable_prefix_refuses_and_changes_nothing() {
let root = temp_root("ambiguous_prefix");
let (runtime, _name) = runtime_at(&root);
let store = store_at(&root).await;
let id_a = seed_candidate(&store, &runtime, "orders").await;
let id_b = seed_candidate(&store, &runtime, "returns").await;
assert_ne!(id_a, id_b);
let ambiguous = "ki-";
let (acode, aout, aerr) = run_headless(
ContractsCommand::Decide {
prefix: ambiguous.into(),
decision: saya_cli::ReviewDecisionArg::Confirm,
profile: None,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_ne!(acode, 0, "ambiguous prefix must not succeed: {aout}{aerr}");
let combined = format!("{aout}{aerr}");
assert!(
combined.contains("more than one claim"),
"ambiguous refusal must name why: {combined}"
);
assert!(
!combined.contains(ambiguous),
"refusal echoed the prefix: {combined}"
);
for id in [&id_a, &id_b] {
let item = store
.get_knowledge_item(id.as_str())
.await
.unwrap()
.expect("item present");
assert_eq!(
item.state,
KnowledgeState::Pending,
"ambiguous changed {id}"
);
}
let (ncode, nout, nerr) = run_headless(
ContractsCommand::Decide {
prefix: "ki-zzzzz".into(),
decision: saya_cli::ReviewDecisionArg::Confirm,
profile: None,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_ne!(
ncode, 0,
"unresolvable prefix must not succeed: {nout}{nerr}"
);
let ncombined = format!("{nout}{nerr}");
assert!(
ncombined.contains("no claim matches"),
"unresolvable refusal must name why: {ncombined}"
);
assert!(
!ncombined.contains("ki-zzzzz"),
"unresolvable refusal echoed the prefix: {ncombined}"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn stale_prefix_refuses_rather_than_write_the_wrong_claim() {
let root = temp_root("stale_prefix");
let (runtime, _name) = runtime_at(&root);
let store = store_at(&root).await;
let _id_a = seed_candidate(&store, &runtime, "orders").await;
let id_b = seed_candidate(&store, &runtime, "returns").await;
let (code, out, err) = run_headless(
ContractsCommand::Decide {
prefix: "ki-".into(),
decision: saya_cli::ReviewDecisionArg::Confirm,
profile: None,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_ne!(code, 0, "ambiguous stale prefix must refuse: {out}{err}");
for id in [&_id_a, &id_b] {
let item = store
.get_knowledge_item(id.as_str())
.await
.unwrap()
.expect("item present");
assert_eq!(item.state, KnowledgeState::Pending);
}
run_headless(
ContractsCommand::Forget {
claim_id: id_b.as_str().into(),
reason: ForgetReasonArg::UserRequest,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
let (fcode, fout, ferr) = run_headless(
ContractsCommand::Decide {
prefix: id_b.as_str().into(),
decision: saya_cli::ReviewDecisionArg::Confirm,
profile: None,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_ne!(
fcode, 0,
"forgotten item's prefix must not confirm: {fout}{ferr}"
);
let item = store
.get_knowledge_item(id_b.as_str())
.await
.unwrap()
.expect("tombstone present");
assert_ne!(
item.state,
KnowledgeState::Active,
"forgotten was confirmed"
);
let _ = fs::remove_dir_all(root);
}
#[tokio::test]
async fn usage_errors_never_echo_user_argument_text() {
let secret = "c-SUPERSECRET-leaked-value";
let bad = parse_slash_command("/confirm").unwrap_err();
assert!(!bad.to_string().contains(secret), "echoed: {bad}");
assert!(!bad.to_string().is_empty());
let bad = parse_slash_command("/confirm c-abc c-def").unwrap_err();
assert!(
!bad.to_string().contains("c-abc"),
"echoed untrusted input: {bad}"
);
assert!(
!bad.to_string().contains("c-def"),
"echoed untrusted input: {bad}"
);
let parsed = parse_slash_command(&format!("/confirm {secret}"));
assert!(parsed.is_ok(), "single token should parse: {parsed:?}");
let cmd = match parsed.unwrap() {
Some(SlashCommand::Contracts(c)) => c,
other => panic!("expected Contracts, got {other:?}"),
};
let _ = cmd;
}
#[tokio::test]
async fn queue_and_existing_subcommands_unchanged() {
let root = temp_root("unchanged");
let (runtime, _name) = runtime_at(&root);
let store = store_at(&root).await;
let id = seed_candidate(&store, &runtime, "orders").await;
let (_qcmd, qcode, qout, qerr) =
run_slash("/queue", &runtime, &store, RenderFormat::Text).await;
assert_eq!(qcode, 0, "/queue stderr: {qerr}");
assert!(
qout.contains(id.as_str()),
"/queue still lists candidates: {qout}"
);
let (_ccmd, ccode, _cout, cerr) =
run_slash("/contracts", &runtime, &store, RenderFormat::Text).await;
assert_eq!(ccode, 0, "/contracts stderr: {cerr}");
run_headless(
ContractsCommand::Decide {
prefix: id.as_str().into(),
decision: saya_cli::ReviewDecisionArg::Confirm,
profile: None,
},
&runtime,
&store,
RenderFormat::Text,
)
.await;
let (_ccmd2, _ccode2, cout2, cerr2) =
run_slash("/contracts", &runtime, &store, RenderFormat::Text).await;
assert!(
cout2.contains("orders"),
"/contracts lists a confirmed claim: {cout2}"
);
assert_eq!(cerr2, "");
let (_scmd, scode, sout, serr) = run_slash(
"/contract analytics.public.orders",
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_eq!(scode, 0, "/contract stderr: {serr}");
assert!(sout.contains("orders"), "/contract still shows: {sout}");
let (_fcmd, fcode, fout, ferr) = run_slash(
&format!("/forget {id}"),
&runtime,
&store,
RenderFormat::Text,
)
.await;
assert_eq!(fcode, 0, "/forget stderr: {ferr}");
assert!(
fout.contains("forgotten"),
"/forget still tombstones: {fout}"
);
let _ = fs::remove_dir_all(root);
}