Skip to main content

talon_core/contracts/
mod.rs

1//! MCP and CLI tool contracts — shared envelope, path, and primitive types.
2
3mod accessors;
4#[cfg(test)]
5#[allow(clippy::unwrap_used, clippy::expect_used)]
6mod envelope_tests;
7
8use serde::{Deserialize, Serialize};
9
10use crate::constants::DEFAULT_LIMIT;
11use crate::error::{ErrorCode, TalonError, TalonResult};
12
13// ── Positive count ──────────────────────────────────────────────────────────
14
15/// A positive count accepted at the tool boundary.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17#[serde(try_from = "u16", into = "u16")]
18pub struct PositiveCount(u16);
19
20impl Default for PositiveCount {
21    fn default() -> Self {
22        Self(DEFAULT_LIMIT)
23    }
24}
25
26impl PositiveCount {
27    /// Builds a positive count.
28    ///
29    /// # Errors
30    ///
31    /// Returns [`TalonError::InvalidInput`] when `value` is zero.
32    pub fn new(value: u16, field: &'static str) -> TalonResult<Self> {
33        if value == 0 {
34            return Err(TalonError::InvalidInput {
35                field,
36                message: "must be greater than zero".to_string(),
37            });
38        }
39        Ok(Self(value))
40    }
41
42    /// Returns the primitive count.
43    #[must_use]
44    pub const fn get(self) -> u16 {
45        self.0
46    }
47
48    /// Constructs directly from a known-valid constant (bypasses the zero-check).
49    pub(crate) const fn from_const(value: u16) -> Self {
50        Self(value)
51    }
52}
53
54impl TryFrom<u16> for PositiveCount {
55    type Error = TalonError;
56
57    fn try_from(value: u16) -> Result<Self, Self::Error> {
58        Self::new(value, "count")
59    }
60}
61
62impl From<PositiveCount> for u16 {
63    fn from(value: PositiveCount) -> Self {
64        value.0
65    }
66}
67
68// ── Path types ──────────────────────────────────────────────────────────────
69
70/// Vault-relative path returned by Talon.
71#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
72#[serde(transparent)]
73pub struct VaultPath(String);
74
75impl VaultPath {
76    /// Parses a non-empty vault-relative path.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`TalonError::InvalidInput`] when the path is empty.
81    pub fn parse(value: impl Into<String>) -> TalonResult<Self> {
82        let value = value.into();
83        if value.trim().is_empty() {
84            return Err(TalonError::InvalidInput {
85                field: "path",
86                message: "must not be empty".to_string(),
87            });
88        }
89        Ok(Self(value))
90    }
91
92    /// Returns the path as a string slice.
93    #[must_use]
94    pub fn as_str(&self) -> &str {
95        &self.0
96    }
97}
98
99/// Container-absolute path used when a tool needs absolute addressing.
100#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
101#[serde(transparent)]
102pub struct ContainerPath(String);
103
104impl ContainerPath {
105    /// Parses a non-empty container path.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`TalonError::InvalidInput`] when the path is empty.
110    pub fn parse(value: impl Into<String>) -> TalonResult<Self> {
111        let value = value.into();
112        if value.trim().is_empty() {
113            return Err(TalonError::InvalidInput {
114                field: "path",
115                message: "must not be empty".to_string(),
116            });
117        }
118        Ok(Self(value))
119    }
120
121    /// Returns a root (`/`) container path. Infallible alternative to `parse("/")`.
122    #[must_use]
123    pub fn root() -> Self {
124        Self("/".to_string())
125    }
126
127    /// Returns the path as a string slice.
128    #[must_use]
129    pub fn as_str(&self) -> &str {
130        &self.0
131    }
132}
133
134// ── Response metadata ───────────────────────────────────────────────────────
135
136/// Metadata included in every successful response.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct ResponseMeta {
140    /// Duration in milliseconds.
141    pub duration_ms: u64,
142    /// Number of results returned.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub result_count: Option<u32>,
145    /// Warnings produced during the call.
146    #[serde(default)]
147    pub warnings: Vec<String>,
148    /// Resolved active scope set, where applicable.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub scope_set: Option<Vec<String>>,
151    /// Resolved absolute timestamp, if `--since` was given.
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub since: Option<String>,
154}
155
156/// Error envelope used when `ok: false`.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct ErrorEnvelope {
160    /// Error code from the fixed enum.
161    pub code: ErrorCode,
162    /// Human-readable error message.
163    pub message: String,
164    /// Optional structured context.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub detail: Option<serde_json::Value>,
167}
168
169// ── Response data envelope ──────────────────────────────────────────────────
170
171use crate::indexing::{InspectResponse, StatusResponse, SyncResponse};
172use crate::query::{ChangesResponse, MetaResponse, ReadResponse, RecallResponse, RelatedResponse};
173use crate::search::SearchResponse;
174
175/// Action-discriminated response payload, serialized inside `TalonEnvelope.data`.
176///
177/// When serialized, produces `{ action: "<action>", ...fields }` — the action
178/// discriminator is redundant with the envelope's top-level `action` but kept
179/// for forward-compatibility with MCP tool call results.
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(tag = "action", rename_all = "kebab-case")]
182pub enum TalonResponseData {
183    /// Search response.
184    Search(SearchResponse),
185    /// Vault-grounded natural-language answer response.
186    Ask(crate::query::AskResponse),
187    /// Read response.
188    Read(ReadResponse),
189    /// Sync response.
190    Sync(SyncResponse),
191    /// Status response.
192    Status(StatusResponse),
193    /// Related-note response.
194    Related(RelatedResponse),
195    /// Frontmatter query response.
196    Meta(MetaResponse),
197    /// Change feed response.
198    Changes(ChangesResponse),
199    /// Inspect check response.
200    Inspect(InspectResponse),
201    /// Vault-native context recall response.
202    Recall(RecallResponse),
203}
204
205/// Unified output envelope for all Talon responses.
206///
207/// Every JSON response uses this shape:
208/// - Success: `{ action, version, ok: true, data: ..., meta: ... }`
209/// - Error:  `{ action, version, ok: false, error: { code, message, detail } }`
210///
211/// See Decision 8 in the design spec for the locked contract.
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213pub struct TalonEnvelope {
214    /// Action name in kebab-case (e.g. "search", "sync", "status").
215    pub action: String,
216    /// Cargo package version at build time.
217    pub version: String,
218    /// Whether the call succeeded.
219    pub ok: bool,
220    /// Action-discriminated payload (present when `ok: true`).
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub data: Option<TalonResponseData>,
223    /// Metadata (present when `ok: true`).
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub meta: Option<ResponseMeta>,
226    /// Error envelope (present when `ok: false`).
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub error: Option<ErrorEnvelope>,
229}
230
231impl TalonEnvelope {
232    /// Builds a success envelope.
233    #[must_use]
234    pub fn ok(action: &'static str, data: TalonResponseData, meta: ResponseMeta) -> Self {
235        Self {
236            action: action.to_string(),
237            version: env!("CARGO_PKG_VERSION").to_string(),
238            ok: true,
239            data: Some(data),
240            meta: Some(meta),
241            error: None,
242        }
243    }
244
245    /// Builds an error envelope.
246    #[must_use]
247    pub fn err(action: &str, error: ErrorEnvelope) -> Self {
248        Self {
249            action: action.to_string(),
250            version: env!("CARGO_PKG_VERSION").to_string(),
251            ok: false,
252            data: None,
253            meta: None,
254            error: Some(error),
255        }
256    }
257
258    /// Returns the inner response data, if present.
259    #[must_use]
260    pub const fn data(&self) -> Option<&TalonResponseData> {
261        self.data.as_ref()
262    }
263
264    /// Returns the inner response data, if present and mutable.
265    #[must_use]
266    #[allow(clippy::missing_const_for_fn)]
267    pub fn data_mut(&mut self) -> Option<&mut TalonResponseData> {
268        self.data.as_mut()
269    }
270
271    /// Extracts the inner response data.
272    #[must_use]
273    #[allow(clippy::missing_const_for_fn)]
274    pub fn into_data(self) -> Option<TalonResponseData> {
275        self.data
276    }
277
278    /// Returns the human-readable response for this envelope.
279    #[must_use]
280    #[allow(clippy::missing_const_for_fn)]
281    pub fn as_response(&self) -> Option<&dyn TalonResponseTrait> {
282        self.data.as_ref().map(|d| d as &dyn TalonResponseTrait)
283    }
284}
285
286/// Trait for accessing response data from `TalonResponseData`.
287///
288/// Implemented by each response variant so output formatters can match on
289/// the inner type without knowing the enum discriminant.
290pub trait TalonResponseTrait {
291    /// Returns the action name.
292    fn action(&self) -> &str;
293}
294
295// ── Tool input envelope ─────────────────────────────────────────────────────
296
297use crate::indexing::{InspectInput, StatusInput, SyncInput};
298use crate::query::{ChangesInput, MetaInput, ReadInput, RecallInput, RelatedInput};
299use crate::search::SearchInput;
300
301/// Tool input envelope.
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303#[serde(tag = "action", rename_all = "kebab-case")]
304pub enum TalonInput {
305    /// Search request.
306    Search(SearchInput),
307    /// Read request.
308    Read(ReadInput),
309    /// Sync/index request.
310    Sync(SyncInput),
311    /// Status request.
312    Status(StatusInput),
313    /// Related-note request.
314    Related(RelatedInput),
315    /// Frontmatter query request.
316    Meta(MetaInput),
317    /// Change feed request.
318    Changes(ChangesInput),
319    /// Inspect check request.
320    Inspect(InspectInput),
321    /// Vault-native context recall request.
322    Recall(RecallInput),
323}