Skip to main content

saya_cli/commands/
contracts.rs

1//! The headless `saya contracts` adapter: resolves arguments, calls the
2//! `crate::contracts` operations, maps results to render DTOs, and emits them.
3//!
4//! No policy lives here. What may be recalled, what conflicts, what is stale,
5//! and whether a claim may be stored confirmed are all decided in
6//! `crate::contracts` and the store — this module only resolves a profile, builds
7//! the typed request, and renders the typed result. 2b-4's slash/agent adapters
8//! call the same operations and must not need to duplicate anything here.
9//!
10//! Dispatch and shared helpers live here; profile resolution is in
11//! `contracts_profile.rs`, read commands in `contracts_read.rs`, write commands
12//! in `contracts_write.rs`, and view→DTO mapping in `contracts_map.rs`.
13
14mod contracts_decide;
15mod contracts_map;
16mod contracts_profile;
17mod contracts_read;
18mod contracts_remember_schema;
19mod contracts_write;
20
21// The identity-dropping `RetrievedContract → ContractView` mapping, re-exported
22// `pub(crate)` so the agent contract tools (2b-3a) reuse it instead of carrying
23// a second mapping that could leak the opaque profile identity.
24pub(crate) use contracts_map::contract_view;
25pub(crate) use contracts_map::queue_item_view;
26// The identity-dropping profile resolution, re-exported `pub(crate)` so the
27// preferences adapter (5c-2) reuses it rather than carrying a second one.
28pub(crate) use contracts_profile::resolve_profile;
29
30use super::output::failure_message;
31use crate::cli::ContractsCommand;
32use crate::config::runtime::RuntimeConfig;
33use crate::contracts::ContractOpError;
34use crate::contracts::SchemaAvailability;
35use crate::render::RenderFormat;
36use saya_store::{SchemaStore, SqliteStateStore};
37use saya_types::{ClaimId, FINGERPRINT_VERSION, ProfileIdentity, SchemaFingerprint, SchemaTree};
38
39/// Exit code for any typed contract-command failure (usage error, op error, or a
40/// write against an unavailable store). Matches the user-error code `config`
41/// uses; the scheme here is ad-hoc per command like the rest of the crate.
42pub(super) const EXIT_CONTRACT_ERROR: i32 = 2;
43
44pub async fn run_contracts(
45    command: ContractsCommand,
46    runtime: &RuntimeConfig,
47    format: RenderFormat,
48    store: &SqliteStateStore,
49) -> Result<i32, Box<dyn std::error::Error>> {
50    match command {
51        ContractsCommand::List { profile } => {
52            contracts_read::list(store, runtime, format, profile.as_deref()).await
53        }
54        ContractsCommand::Show { table, profile } => {
55            contracts_read::show(store, runtime, format, &table, profile.as_deref()).await
56        }
57        ContractsCommand::Queue { profile, limit } => {
58            contracts_read::queue(store, runtime, format, profile.as_deref(), limit).await
59        }
60        ContractsCommand::Remember {
61            table,
62            kind,
63            value,
64            column,
65            reason,
66            profile,
67        } => match resolve_profile(runtime, profile.as_deref()) {
68            Ok((name, identity)) => {
69                contracts_write::remember(
70                    contracts_write::RememberRequest {
71                        table: &table,
72                        kind,
73                        value: &value,
74                        column: column.as_deref(),
75                        reason: reason.as_deref(),
76                    },
77                    contracts_write::RememberContext {
78                        store,
79                        format,
80                        profile_name: &name,
81                        identity: &identity,
82                    },
83                )
84                .await
85            }
86            Err((code, message)) => failure_message(code, message, format),
87        },
88        ContractsCommand::Review {
89            claim_id,
90            confirm,
91            reject,
92        } => contracts_write::review(store, format, &claim_id, confirm, reject).await,
93        ContractsCommand::Decide {
94            prefix,
95            decision,
96            profile,
97        } => match resolve_profile(runtime, profile.as_deref()) {
98            Ok((name, identity)) => {
99                contracts_decide::decide(store, format, &prefix, decision, &name, &identity).await
100            }
101            Err((code, message)) => failure_message(code, message, format),
102        },
103        ContractsCommand::Forget { claim_id, reason } => {
104            contracts_write::forget_claim(store, format, &claim_id, reason).await
105        }
106    }
107}
108
109/// Emits a typed contract-operation error and returns the contract error exit
110/// code. `ContractOpError` is payload-free, so no identity or value can leak.
111pub(super) fn op_failure(
112    error: ContractOpError,
113    format: RenderFormat,
114) -> Result<i32, Box<dyn std::error::Error>> {
115    failure_message(EXIT_CONTRACT_ERROR, error.to_string(), format)
116}
117
118/// Emits a payload-free argument-error message and returns the contract error
119/// exit code: malformed table, bad value, ambiguous review flags.
120pub(super) fn arg_failure(
121    message: ArgMessage,
122    format: RenderFormat,
123) -> Result<i32, Box<dyn std::error::Error>> {
124    failure_message(EXIT_CONTRACT_ERROR, message.to_string(), format)
125}
126
127/// The "no schema observed" fingerprint: current format, all-zero digest. The
128/// headless adapter has no live schema to fingerprint, so it stores a digest
129/// guaranteed never to equal a real schema's — a later live schema reads the
130/// claim as `needs_review` (or `stale` if a referenced column is gone), never as
131/// `current`. Fabricating a real-looking digest would risk a false match.
132///
133/// `pub(crate)` so the agent contract tools (2b-3a) reuse this same sentinel
134/// instead of inventing a second all-zero digest convention.
135pub(crate) fn unobserved_fingerprint() -> SchemaFingerprint {
136    SchemaFingerprint::from_parts(FINGERPRINT_VERSION, &"0".repeat(64))
137        .expect("current format with a 64-hex-zero digest is a valid fingerprint")
138}
139
140/// The cached schema for `identity`, or `None` when nothing is cached. Mirrors
141/// the agent recall path's `SchemaStore::get_schema` lookup, but without the
142/// `.unwrap_or_default()` that collapses "no cache" onto an empty tree: a
143/// missing cache stays `None` so validity reads the honest
144/// `live_schema_unavailable`, while a cached-but-empty tree reads `stale`. A
145/// store read failure degrades to `None` — not a crash on a read path.
146///
147/// Shared by the read commands (`list`/`show`), the write command (`remember`),
148/// and the agent contract tools (`contract_search`), so every adapter that
149/// classifies a claim against the cached schema does it the same way — never a
150/// second lookup convention.
151pub(crate) async fn cached_schema(
152    store: &SqliteStateStore,
153    identity: &ProfileIdentity,
154) -> Option<SchemaTree> {
155    store
156        .get_schema(identity.as_str())
157        .await
158        .ok()
159        .flatten()
160        .map(|cached| cached.schema)
161}
162
163/// The schema known for a profile as a three-state [`SchemaAvailability`]: a
164/// real cache (`Available`, carrying when it was observed), no cache entry yet
165/// (`Missing`), or a store that could not be read (`Unavailable`). The read
166/// commands and the agent contract tools use this — *not* [`cached_schema`] —
167/// so a store error or an undiscovered profile classifies `LiveSchemaUnavailable`
168/// instead of collapsing to an empty tree that would read `Stale` (the P1 bug).
169/// The write path (`remember`/`import`) keeps [`cached_schema`]: it resolves a
170/// fingerprint against whatever the cache holds, which is not a classification.
171pub(crate) async fn cached_schema_availability(
172    store: &SqliteStateStore,
173    identity: &ProfileIdentity,
174) -> SchemaAvailability {
175    match store.get_schema(identity.as_str()).await {
176        Ok(Some(cached)) => SchemaAvailability::available(cached.schema, cached.updated_unix_ms),
177        Ok(None) => SchemaAvailability::Missing,
178        Err(_) => SchemaAvailability::Unavailable,
179    }
180}
181
182/// Parses a claim id, mapping a malformed one to a payload-free typed message
183/// so an untrusted id never reaches the terminal.
184pub(super) fn parse_claim_id(input: &str) -> Result<ClaimId, String> {
185    ClaimId::parse(input).map_err(|_| ArgMessage::MalformedClaimId.to_string())
186}
187
188/// Payload-free argument-error messages, so untrusted input (a bad table name,
189/// a bad claim id, a value the store refused) never reaches the terminal.
190#[derive(Debug, Clone, Copy)]
191pub(super) enum ArgMessage {
192    MalformedTable,
193    MalformedClaimId,
194    BadValue,
195    AmbiguousReview,
196    /// A short-reference prefix matched more than one claim (spec D). The typed
197    /// prefixes never reach the message — the user must type more characters.
198    AmbiguousPrefix,
199    /// A short-reference prefix matched no claim (spec D). Payload-free: the
200    /// prefix the user typed is untrusted and never echoed.
201    PrefixNotFound,
202}
203
204impl std::fmt::Display for ArgMessage {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        match self {
207            Self::MalformedTable => write!(
208                f,
209                "qualified name must be exactly three dot-separated parts: catalog.schema.object"
210            ),
211            Self::MalformedClaimId => write!(f, "claim id must be alphanumeric, '-', or '_'"),
212            Self::BadValue => write!(f, "claim value is invalid"),
213            Self::AmbiguousReview => write!(f, "choose exactly one of --confirm or --reject"),
214            Self::AmbiguousPrefix => write!(
215                f,
216                "that claim reference matches more than one claim; type more characters"
217            ),
218            Self::PrefixNotFound => write!(
219                f,
220                "no claim matches that reference; it may have been forgotten, or the prefix is too short"
221            ),
222        }
223    }
224}