Skip to main content

tapes_client/core/models/
skill.rs

1//! Skill shapes.
2//!
3//! These are the one corner of the contract that speaks camelCase — the
4//! console's skills schemas predate the snake_case convention the rest of tapes
5//! uses. The models carry snake_case field names with the wire spelling
6//! attached, so a Rust call site reads like Rust and the bytes stay the
7//! document's.
8
9use serde::{Deserialize, Serialize};
10
11use super::ContractModel;
12
13/// The unified Skill shape the console expects (camelCase).
14///
15/// Models the contract's `skillResponse` schema.
16#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
17#[serde(default)]
18#[non_exhaustive]
19pub struct SkillResponse {
20    /// The contract's `authorId`.
21    #[serde(rename = "authorId")]
22    pub author_id: String,
23
24    /// The contract's `content`.
25    pub content: String,
26
27    /// The contract's `createdAt`.
28    #[serde(rename = "createdAt")]
29    pub created_at: String,
30
31    /// The contract's `description`.
32    pub description: String,
33
34    /// The contract's `downloadCount`.
35    #[serde(rename = "downloadCount")]
36    pub download_count: i64,
37
38    /// The contract's `id`.
39    pub id: String,
40
41    /// The contract's `isAiGenerated`.
42    #[serde(rename = "isAiGenerated")]
43    pub is_ai_generated: bool,
44
45    /// The contract's `name`.
46    pub name: String,
47
48    /// The contract's `originatingSessionIds`.
49    #[serde(
50        rename = "originatingSessionIds",
51        deserialize_with = "super::null_default"
52    )]
53    pub originating_session_ids: Vec<String>,
54
55    /// The contract's `parentId` — null unless the skill is a
56    /// duplicate/fork, in the contract's own words. The schema does not
57    /// mark it nullable, so the coverage gate cannot hold this shape; the
58    /// prose and every real listing do.
59    #[serde(rename = "parentId", default)]
60    pub parent_id: Option<String>,
61
62    /// The contract's `slug`.
63    pub slug: String,
64
65    /// The contract's `tags`.
66    #[serde(deserialize_with = "super::null_default")]
67    pub tags: Vec<String>,
68
69    /// The contract's `type`.
70    #[serde(rename = "type")]
71    pub type_: String,
72
73    /// The contract's `updatedAt`.
74    #[serde(rename = "updatedAt")]
75    pub updated_at: String,
76
77    /// The contract's `version`.
78    pub version: String,
79
80    /// The contract's `visibility`.
81    pub visibility: String,
82}
83
84impl ContractModel for SkillResponse {
85    const SCHEMA: &'static str = "skillResponse";
86}
87
88/// The paginated list envelope: one keyset page plus
89/// the opaque next_cursor (mirroring /v1/sessions) and the per-tab counts for
90/// the active search.
91///
92/// Models the contract's `skillsListResponse` schema.
93#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
94#[serde(default)]
95#[non_exhaustive]
96pub struct SkillsListResponse {
97    /// The contract's `counts`.
98    #[serde(deserialize_with = "super::null_default")]
99    pub counts: SkillCounts,
100
101    /// The contract's `items`.
102    #[serde(deserialize_with = "super::null_default")]
103    pub items: Vec<SkillResponse>,
104
105    /// The contract's `next_cursor`.
106    pub next_cursor: String,
107}
108
109impl ContractModel for SkillsListResponse {
110    const SCHEMA: &'static str = "skillsListResponse";
111}
112
113/// The tab counts for the current search: all matching,
114/// authored by the caller (mine), and everyone else's (team = all - mine).
115///
116/// Models the contract's `skillCountsResp` schema.
117#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
118#[serde(default)]
119#[non_exhaustive]
120pub struct SkillCounts {
121    /// The contract's `all`.
122    pub all: i64,
123
124    /// The contract's `mine`.
125    pub mine: i64,
126
127    /// The contract's `team`.
128    pub team: i64,
129}
130
131impl ContractModel for SkillCounts {
132    const SCHEMA: &'static str = "skillCountsResp";
133}
134
135/// One immutable published snapshot.
136///
137/// Models the contract's `skillVersionResponse` schema.
138#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
139#[serde(default)]
140#[non_exhaustive]
141pub struct SkillVersionResponse {
142    /// The contract's `authorId`.
143    #[serde(rename = "authorId")]
144    pub author_id: String,
145
146    /// The contract's `changelog`.
147    pub changelog: String,
148
149    /// The contract's `content`.
150    pub content: String,
151
152    /// The contract's `id`.
153    pub id: String,
154
155    /// The contract's `publishedAt`.
156    #[serde(rename = "publishedAt")]
157    pub published_at: String,
158
159    /// The contract's `semver`.
160    pub semver: String,
161
162    /// The contract's `skillId`.
163    #[serde(rename = "skillId")]
164    pub skill_id: String,
165
166    /// The contract's `versionNumber`.
167    #[serde(rename = "versionNumber")]
168    pub version_number: i32,
169}
170
171impl ContractModel for SkillVersionResponse {
172    const SCHEMA: &'static str = "skillVersionResponse";
173}
174
175/// The full version history for one skill, newest
176/// first.
177///
178/// Models the contract's `skillVersionsResponse` schema.
179#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
180#[serde(default)]
181#[non_exhaustive]
182pub struct SkillVersionsResponse {
183    /// The contract's `totalCount`.
184    #[serde(rename = "totalCount")]
185    pub total_count: i32,
186
187    /// The contract's `versions`.
188    #[serde(deserialize_with = "super::null_default")]
189    pub versions: Vec<SkillVersionResponse>,
190}
191
192impl ContractModel for SkillVersionsResponse {
193    const SCHEMA: &'static str = "skillVersionsResponse";
194}
195
196/// The envelope for the skills attributed to one
197/// session.
198///
199/// Models the contract's `sessionSkillsResponse` schema.
200#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
201#[serde(default)]
202#[non_exhaustive]
203pub struct SessionSkillsResponse {
204    /// The contract's `items`.
205    #[serde(deserialize_with = "super::null_default")]
206    pub items: Vec<SkillResponse>,
207}
208
209impl ContractModel for SessionSkillsResponse {
210    const SCHEMA: &'static str = "sessionSkillsResponse";
211}
212
213/// The POST /v1/skills body for an authored-from-
214/// scratch skill — only a name is required; the rest default to an empty
215/// private draft.
216///
217/// Models the contract's `createSkillRequest` schema.
218#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
219#[serde(default)]
220pub struct CreateSkillRequest {
221    /// The contract's `content`.
222    pub content: String,
223
224    /// The contract's `description`.
225    pub description: String,
226
227    /// The contract's `name`.
228    pub name: String,
229
230    /// The contract's `tags`.
231    #[serde(deserialize_with = "super::null_default")]
232    pub tags: Vec<String>,
233
234    /// The contract's `type`.
235    #[serde(rename = "type")]
236    pub type_: String,
237}
238
239impl ContractModel for CreateSkillRequest {
240    const SCHEMA: &'static str = "createSkillRequest";
241}
242
243/// The PUT /v1/skills/:slug body — all fields optional;
244/// only present fields are applied onto the existing record.
245///
246/// Every field is an [`Option`] that is omitted from the wire when unset,
247/// because "only present fields are applied" makes an absent field the *only*
248/// way to say "leave this one alone". A body that spelled all six every time
249/// would turn a one-field rename into a five-field erasure: the server would
250/// dutifully apply the empty content, the empty description, and the empty tag
251/// list it was sent.
252///
253/// Models the contract's `updateSkillRequest` schema.
254#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
255#[serde(default)]
256pub struct UpdateSkillRequest {
257    /// The contract's `content`.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub content: Option<String>,
260
261    /// The contract's `description`.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub description: Option<String>,
264
265    /// The contract's `name`.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub name: Option<String>,
268
269    /// The contract's `tags`.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub tags: Option<Vec<String>>,
272
273    /// The contract's `type`.
274    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
275    pub type_: Option<String>,
276
277    /// The contract's `visibility`.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub visibility: Option<String>,
280}
281
282impl ContractModel for UpdateSkillRequest {
283    const SCHEMA: &'static str = "updateSkillRequest";
284}
285
286/// The POST /v1/skills/:slug/versions body.
287///
288/// Models the contract's `publishSkillRequest` schema.
289#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
290#[serde(default)]
291pub struct PublishSkillRequest {
292    /// The contract's `changelog`.
293    pub changelog: String,
294
295    /// The contract's `content`.
296    pub content: String,
297}
298
299impl ContractModel for PublishSkillRequest {
300    const SCHEMA: &'static str = "publishSkillRequest";
301}
302
303/// The POST /v1/skills/generate body. It mirrors the
304/// console's GenerateSkillInput: the client nominates source sessions plus
305/// optional hints, and the server is authoritative on the skill body.
306///
307/// Models the contract's `generateSkillRequest` schema.
308#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
309#[serde(default)]
310pub struct GenerateSkillRequest {
311    /// The contract's `hint`.
312    #[serde(deserialize_with = "super::null_default")]
313    pub hint: GenerateSkillRequestHint,
314
315    /// The contract's `sessionIds`.
316    #[serde(rename = "sessionIds", deserialize_with = "super::null_default")]
317    pub session_ids: Vec<String>,
318}
319
320impl ContractModel for GenerateSkillRequest {
321    const SCHEMA: &'static str = "generateSkillRequest";
322}
323
324/// The optional authoring hints on a generate request.
325///
326/// Part of a request body, so it is constructible: not `non_exhaustive`, and
327/// every field public. The whole point of the type is that a caller fills one
328/// in — a hint nobody outside this crate could set would leave
329/// [`GenerateSkillRequest::hint`] permanently at its default.
330///
331/// Models the inline `hint` object of the contract's `generateSkillRequest`
332/// schema, which the document declares in place rather than as a schema of its
333/// own — so the [`super::coverage`] gate holds these fields through
334/// [`GenerateSkillRequest`] rather than registering them separately.
335#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
336#[serde(default)]
337pub struct GenerateSkillRequestHint {
338    /// The contract's `description`.
339    pub description: String,
340
341    /// The contract's `name`.
342    pub name: String,
343
344    /// The contract's `tags`.
345    #[serde(deserialize_with = "super::null_default")]
346    pub tags: Vec<String>,
347
348    /// The contract's `type`.
349    #[serde(rename = "type")]
350    pub type_: String,
351}
352
353impl SkillsListResponse {
354    /// This listing as one page of the crate's pagination convention.
355    ///
356    /// See [`crate::core::models::SessionListResponse::into_page`]: the skills
357    /// envelope pages the same way, so it walks through the same loop.
358    #[must_use]
359    pub fn into_page(self) -> crate::page::Page<SkillResponse> {
360        crate::page::Page {
361            items: self.items,
362            next_cursor: Some(self.next_cursor),
363        }
364    }
365}
366
367#[cfg(test)]
368#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
369mod parent_id_tests {
370    use super::SkillResponse;
371
372    #[test]
373    fn a_root_skill_with_null_parent_decodes() {
374        // Every skill that is not a duplicate/fork carries `parentId: null` —
375        // the contract's prose says so and every real listing shows it. A
376        // non-optional field here made every `skill list` fail on the first
377        // root skill, against core and cassette alike.
378        let row = serde_json::json!({
379            "id": "skl-1", "slug": "s", "name": "n", "description": "d",
380            "type": "workflow", "version": "0.1.0", "visibility": "private",
381            "tags": [], "content": "c", "isAiGenerated": false,
382            "originatingSessionIds": [], "authorId": "user_1",
383            "downloadCount": 0, "parentId": null,
384            "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z"
385        });
386        let parsed: SkillResponse = serde_json::from_value(row)
387            .unwrap_or_else(|e| panic!("null parentId must decode: {e}"));
388        assert_eq!(parsed.parent_id, None);
389
390        let fork = serde_json::json!({
391            "id": "skl-2", "slug": "s2", "name": "n", "description": "d",
392            "type": "workflow", "version": "0.1.0", "visibility": "private",
393            "tags": [], "content": "c", "isAiGenerated": false,
394            "originatingSessionIds": [], "authorId": "user_1",
395            "downloadCount": 0, "parentId": "skl-1",
396            "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z"
397        });
398        let parsed: SkillResponse =
399            serde_json::from_value(fork).unwrap_or_else(|e| panic!("fork must decode: {e}"));
400        assert_eq!(parsed.parent_id.as_deref(), Some("skl-1"));
401    }
402}