Skip to main content

tapes_client/core/models/
params.rs

1//! Typed parameters for the sealed operations that take them.
2//!
3//! # Why these are types and not `Vec<(&str, String)>`
4//!
5//! The untyped form is still there — [`crate::core::CoreClient::call`] takes
6//! wire-named pairs, and always will, because that is what makes an operation
7//! this crate never named still reachable. But a pair list is checked against
8//! the contract at *runtime*: a misspelled `payolad` is refused when the call
9//! is made, which is late for a name that was wrong the moment it was typed.
10//! These structs move the spelling to compile time and leave the runtime check
11//! exactly where it was, as the backstop for the untyped route.
12//!
13//! # Why a request enum is closed and a response string is not
14//!
15//! [`super`]'s models keep `status` and `kind` as `String` because an added
16//! variant must never fail a decode. A *request* parameter is the opposite
17//! situation: the value is the client's to choose, the contract declares the
18//! closed set the server accepts, and a value outside it is a 400 the client
19//! could have prevented. So where the document declares an `enum`, this module
20//! declares one too — and [`super::coverage`] holds the two together.
21
22use serde::{Deserialize, Serialize};
23
24/// One operation's parameters, in the shape the contract declares them.
25pub trait ContractParams {
26    /// The `operationId` these parameters belong to.
27    const OPERATION: &'static str;
28
29    /// The wire pairs to send: set parameters only, under the contract's own
30    /// names.
31    ///
32    /// An unset optional parameter is omitted rather than sent as a default,
33    /// so the server's default applies and this client never has to be
34    /// updated when one of them changes.
35    fn values(&self) -> Vec<(&'static str, String)>;
36}
37
38/// A parameter whose accepted values the contract closes with an `enum`.
39pub trait ContractEnum: Sized + Copy {
40    /// Where the contract declares this set: `(operationId, parameter)`, once
41    /// per operation that takes it.
42    const DECLARED_BY: &'static [(&'static str, &'static str)];
43
44    /// Every value, in the document's own spelling.
45    const VALUES: &'static [&'static str];
46
47    /// This value, as it travels.
48    fn as_str(self) -> &'static str;
49}
50
51/// How much of a span's payload a trace read should carry.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum PayloadDetail {
55    /// Whole payloads.
56    Full,
57    /// Truncated payloads, with a marker on the spans that were cut.
58    Preview,
59}
60
61impl ContractEnum for PayloadDetail {
62    const DECLARED_BY: &'static [(&'static str, &'static str)] =
63        &[("getSessionTraces", "payload"), ("getTrace", "payload")];
64    const VALUES: &'static [&'static str] = &["full", "preview"];
65
66    fn as_str(self) -> &'static str {
67        match self {
68            Self::Full => "full",
69            Self::Preview => "preview",
70        }
71    }
72}
73
74/// The grain an export is written at.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum ExportDetail {
78    /// One record per span.
79    Spans,
80    /// One record per trace.
81    Traces,
82}
83
84impl ContractEnum for ExportDetail {
85    const DECLARED_BY: &'static [(&'static str, &'static str)] =
86        &[("exportSession", "detail"), ("exportSessions", "detail")];
87    const VALUES: &'static [&'static str] = &["spans", "traces"];
88
89    fn as_str(self) -> &'static str {
90        match self {
91            Self::Spans => "spans",
92            Self::Traces => "traces",
93        }
94    }
95}
96
97/// Which way a listing is ordered.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "lowercase")]
100pub enum SortDirection {
101    /// Oldest, or lowest, first.
102    Asc,
103    /// Newest, or highest, first.
104    Desc,
105}
106
107impl ContractEnum for SortDirection {
108    const DECLARED_BY: &'static [(&'static str, &'static str)] = &[("listSessions", "direction")];
109    const VALUES: &'static [&'static str] = &["asc", "desc"];
110
111    fn as_str(self) -> &'static str {
112        match self {
113            Self::Asc => "asc",
114            Self::Desc => "desc",
115        }
116    }
117}
118
119/// Whose skills a listing covers.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "lowercase")]
122pub enum SkillScope {
123    /// Everything the caller may see.
124    All,
125    /// Only the caller's own.
126    Mine,
127    /// Everyone else's.
128    Team,
129}
130
131impl ContractEnum for SkillScope {
132    const DECLARED_BY: &'static [(&'static str, &'static str)] = &[("listSkills", "scope")];
133    const VALUES: &'static [&'static str] = &["all", "mine", "team"];
134
135    fn as_str(self) -> &'static str {
136        match self {
137            Self::All => "all",
138            Self::Mine => "mine",
139            Self::Team => "team",
140        }
141    }
142}
143
144/// How a skills listing is ordered.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "lowercase")]
147pub enum SkillSort {
148    /// Most downloaded first.
149    Downloads,
150}
151
152impl ContractEnum for SkillSort {
153    const DECLARED_BY: &'static [(&'static str, &'static str)] = &[("listSkills", "sort")];
154    const VALUES: &'static [&'static str] = &["downloads"];
155
156    fn as_str(self) -> &'static str {
157        match self {
158            Self::Downloads => "downloads",
159        }
160    }
161}
162
163/// `GET /v1/sessions` — the sessions listing.
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct SessionListParams {
166    /// How many sessions to return.
167    pub limit: Option<u32>,
168    /// The cursor from a previous page's `next_cursor`.
169    pub cursor: Option<String>,
170    /// Which column to order by.
171    pub sort: Option<String>,
172    /// Which way to order it.
173    pub direction: Option<SortDirection>,
174    /// Lower bound on activity, as an RFC 3339 timestamp.
175    pub since: Option<String>,
176    /// Upper bound on activity, as an RFC 3339 timestamp.
177    pub until: Option<String>,
178    /// With [`Self::harness_session_id`], narrows the point lookup to the
179    /// single session with this harness id. Rejected alone (400): a harness
180    /// id names a harness, not a session.
181    pub harness_id: Option<String>,
182    /// Only sessions with this harness-side id (exact match). Alone it
183    /// matches across all harnesses — at most one row per harness; with
184    /// [`Self::harness_id`] it is a single-harness point lookup. The server
185    /// refuses either combined with `cursor`, `sort`, `direction`, `since`,
186    /// or `until` (400), and ignores `limit` while the filter is active.
187    pub harness_session_id: Option<String>,
188    /// Only sessions captured for this authenticated subject.
189    pub auth_subject: Option<String>,
190}
191
192impl ContractParams for SessionListParams {
193    const OPERATION: &'static str = "listSessions";
194
195    fn values(&self) -> Vec<(&'static str, String)> {
196        let mut values = Vec::new();
197        push_num(&mut values, "limit", self.limit);
198        push(&mut values, "cursor", self.cursor.as_deref());
199        push(&mut values, "sort", self.sort.as_deref());
200        push_enum(&mut values, "direction", self.direction);
201        push(&mut values, "since", self.since.as_deref());
202        push(&mut values, "until", self.until.as_deref());
203        push(&mut values, "harness_id", self.harness_id.as_deref());
204        push(
205            &mut values,
206            "harness_session_id",
207            self.harness_session_id.as_deref(),
208        );
209        push(&mut values, "auth_subject", self.auth_subject.as_deref());
210        values
211    }
212}
213
214/// `GET /v1/sessions/{id}/traces` — the derived span read model.
215#[derive(Debug, Clone, Default, PartialEq, Eq)]
216pub struct SessionTracesParams {
217    /// How much of each span's payload to carry.
218    pub payload: Option<PayloadDetail>,
219}
220
221impl ContractParams for SessionTracesParams {
222    const OPERATION: &'static str = "getSessionTraces";
223
224    fn values(&self) -> Vec<(&'static str, String)> {
225        let mut values = Vec::new();
226        push_enum(&mut values, "payload", self.payload);
227        values
228    }
229}
230
231/// `GET /v1/traces/{trace_id}` — one trace with its spans.
232#[derive(Debug, Clone, Default, PartialEq, Eq)]
233pub struct TraceParams {
234    /// How much of each span's payload to carry.
235    pub payload: Option<PayloadDetail>,
236}
237
238impl ContractParams for TraceParams {
239    const OPERATION: &'static str = "getTrace";
240
241    fn values(&self) -> Vec<(&'static str, String)> {
242        let mut values = Vec::new();
243        push_enum(&mut values, "payload", self.payload);
244        values
245    }
246}
247
248/// `GET /v1/traces` — the trace summaries for one session.
249///
250/// `session_id` is required: the contract scopes this listing with it, and a
251/// call without one is refused before it is sent rather than answering a
252/// different question than the caller asked.
253#[derive(Debug, Clone, Default, PartialEq, Eq)]
254pub struct TraceListParams {
255    /// The session whose traces to list.
256    pub session_id: String,
257}
258
259impl ContractParams for TraceListParams {
260    const OPERATION: &'static str = "listTraces";
261
262    fn values(&self) -> Vec<(&'static str, String)> {
263        vec![("session_id", self.session_id.clone())]
264    }
265}
266
267/// `GET /v1/search/spans` — semantic search over span embeddings.
268///
269/// `query` is required, for the same reason [`TraceListParams::session_id`] is.
270#[derive(Debug, Clone, Default, PartialEq, Eq)]
271pub struct SearchSpansParams {
272    /// The search text.
273    pub query: String,
274    /// How many hits to return.
275    pub top_k: Option<u32>,
276}
277
278impl ContractParams for SearchSpansParams {
279    const OPERATION: &'static str = "searchSpans";
280
281    fn values(&self) -> Vec<(&'static str, String)> {
282        let mut values = vec![("query", self.query.clone())];
283        push_num(&mut values, "top_k", self.top_k);
284        values
285    }
286}
287
288/// `GET /v1/sessions/{id}/export` — one session's export stream.
289#[derive(Debug, Clone, Default, PartialEq, Eq)]
290pub struct ExportSessionParams {
291    /// The grain to write.
292    pub detail: Option<ExportDetail>,
293}
294
295impl ContractParams for ExportSessionParams {
296    const OPERATION: &'static str = "exportSession";
297
298    fn values(&self) -> Vec<(&'static str, String)> {
299        let mut values = Vec::new();
300        push_enum(&mut values, "detail", self.detail);
301        values
302    }
303}
304
305/// `GET /v1/sessions/export` — every session in a window, streamed.
306#[derive(Debug, Clone, Default, PartialEq, Eq)]
307pub struct ExportSessionsParams {
308    /// Lower bound, as an RFC 3339 timestamp.
309    pub since: Option<String>,
310    /// Upper bound, as an RFC 3339 timestamp.
311    pub until: Option<String>,
312    /// The grain to write.
313    pub detail: Option<ExportDetail>,
314}
315
316impl ContractParams for ExportSessionsParams {
317    const OPERATION: &'static str = "exportSessions";
318
319    fn values(&self) -> Vec<(&'static str, String)> {
320        let mut values = Vec::new();
321        push(&mut values, "since", self.since.as_deref());
322        push(&mut values, "until", self.until.as_deref());
323        push_enum(&mut values, "detail", self.detail);
324        values
325    }
326}
327
328/// `GET /v1/skills` — the skills listing.
329#[derive(Debug, Clone, Default, PartialEq, Eq)]
330pub struct SkillsListParams {
331    /// How many skills to return.
332    pub limit: Option<u32>,
333    /// The cursor from a previous page's `next_cursor`.
334    pub cursor: Option<String>,
335    /// Free-text search.
336    pub q: Option<String>,
337    /// Whose skills to list.
338    pub scope: Option<SkillScope>,
339    /// How to order them.
340    pub sort: Option<SkillSort>,
341}
342
343impl ContractParams for SkillsListParams {
344    const OPERATION: &'static str = "listSkills";
345
346    fn values(&self) -> Vec<(&'static str, String)> {
347        let mut values = Vec::new();
348        push_num(&mut values, "limit", self.limit);
349        push(&mut values, "cursor", self.cursor.as_deref());
350        push(&mut values, "q", self.q.as_deref());
351        push_enum(&mut values, "scope", self.scope);
352        push_enum(&mut values, "sort", self.sort);
353        values
354    }
355}
356
357/// `GET /v1/stats` — the aggregate rollups.
358#[derive(Debug, Clone, Default, PartialEq, Eq)]
359pub struct StatsParams {
360    /// Lower bound, as an RFC 3339 timestamp.
361    pub since: Option<String>,
362    /// Upper bound, as an RFC 3339 timestamp.
363    pub until: Option<String>,
364    /// Narrow every total to sessions captured for this authenticated
365    /// subject — the same subject the sessions listing filters by, so totals
366    /// can agree with the rows beside them. Omitted, the totals are org-wide.
367    pub auth_subject: Option<String>,
368}
369
370impl ContractParams for StatsParams {
371    const OPERATION: &'static str = "getStats";
372
373    fn values(&self) -> Vec<(&'static str, String)> {
374        let mut values = Vec::new();
375        push(&mut values, "since", self.since.as_deref());
376        push(&mut values, "until", self.until.as_deref());
377        push(&mut values, "auth_subject", self.auth_subject.as_deref());
378        values
379    }
380}
381
382fn push(values: &mut Vec<(&'static str, String)>, wire: &'static str, value: Option<&str>) {
383    if let Some(value) = value {
384        values.push((wire, value.to_owned()));
385    }
386}
387
388fn push_num(values: &mut Vec<(&'static str, String)>, wire: &'static str, value: Option<u32>) {
389    if let Some(value) = value {
390        values.push((wire, value.to_string()));
391    }
392}
393
394fn push_enum<E: ContractEnum>(
395    values: &mut Vec<(&'static str, String)>,
396    wire: &'static str,
397    value: Option<E>,
398) {
399    if let Some(value) = value {
400        values.push((wire, value.as_str().to_owned()));
401    }
402}
403
404#[cfg(test)]
405#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn an_unset_optional_parameter_is_omitted_rather_than_defaulted() {
411        // The omit-when-unset rule, at the typed layer: sending `limit=50`
412        // because the caller said nothing would pin this client to today's
413        // server default forever.
414        assert!(SessionListParams::default().values().is_empty());
415    }
416
417    #[test]
418    fn a_set_parameter_travels_under_the_contracts_own_name() {
419        let params = SessionListParams {
420            limit: Some(25),
421            direction: Some(SortDirection::Desc),
422            ..Default::default()
423        };
424        assert_eq!(
425            params.values(),
426            vec![("limit", "25".to_owned()), ("direction", "desc".to_owned())],
427        );
428    }
429
430    #[test]
431    fn a_required_parameter_is_always_sent_even_when_it_is_empty() {
432        // Empty is a value the server can reject in its own words; omitting it
433        // is a differently-shaped request the contract layer would refuse.
434        assert_eq!(
435            SearchSpansParams::default().values(),
436            vec![("query", String::new())],
437        );
438    }
439}