Skip to main content

roder_api/
discovery.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3
4use crate::events::{ThreadId, TurnId};
5
6pub type DiscoveryCatalogId = String;
7pub type DiscoveryGroupId = String;
8pub type DiscoveryItemId = String;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
11#[serde(rename_all = "camelCase")]
12pub enum DiscoverySourceKind {
13    InternalTools,
14    McpTools,
15    Skills,
16    Commands,
17    Plugins,
18    Subagents,
19    ArtifactTools,
20    WorkflowImports,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "camelCase")]
25pub enum DiscoveryItemStatus {
26    Available,
27    Disabled,
28    Unavailable,
29    AuthRequired,
30    Error,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "camelCase")]
35pub enum DiscoveryAuthState {
36    NotRequired,
37    Available,
38    Required,
39    Expired,
40    #[default]
41    Unknown,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "camelCase")]
46pub enum DiscoveryLifecycleState {
47    Discovered,
48    Promoted,
49    Reused,
50    WarmCached,
51    Expired,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "camelCase")]
56pub enum DiscoveryPromotionState {
57    NotPromoted,
58    Promoted,
59    Reused,
60    WarmCacheHit,
61    Expired,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(rename_all = "camelCase")]
66pub enum DiscoveryCacheStatus {
67    Cold,
68    Warm,
69    Hit,
70    Expired,
71    Disabled,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "camelCase")]
76pub enum DiscoverySchemaFormat {
77    JsonSchema,
78    Markdown,
79    Toml,
80    Json,
81    PlainText,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "camelCase")]
86pub struct DiscoveryRedaction {
87    #[serde(default)]
88    pub redacted: bool,
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub fields: Vec<String>,
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub secret_refs: Vec<String>,
93}
94
95impl DiscoveryRedaction {
96    pub fn none() -> Self {
97        Self {
98            redacted: false,
99            fields: Vec::new(),
100            secret_refs: Vec::new(),
101        }
102    }
103
104    pub fn secret_refs(secret_refs: impl IntoIterator<Item = impl Into<String>>) -> Self {
105        Self {
106            redacted: true,
107            fields: Vec::new(),
108            secret_refs: secret_refs.into_iter().map(Into::into).collect(),
109        }
110    }
111}
112
113impl Default for DiscoveryRedaction {
114    fn default() -> Self {
115        Self::none()
116    }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "camelCase")]
121pub struct DiscoverySchemaReference {
122    pub format: DiscoverySchemaFormat,
123    pub uri: String,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub content_hash: Option<String>,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub byte_count: Option<u64>,
128    #[serde(default)]
129    pub redaction: DiscoveryRedaction,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133#[serde(rename_all = "camelCase")]
134pub struct DiscoveryCatalogSource {
135    pub kind: DiscoverySourceKind,
136    pub id: String,
137    pub display_name: String,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub origin: Option<String>,
140    #[serde(default)]
141    pub auth_state: DiscoveryAuthState,
142    #[serde(default)]
143    pub redaction: DiscoveryRedaction,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(rename_all = "camelCase")]
148pub struct DiscoveryCatalogItem {
149    pub id: DiscoveryItemId,
150    pub group_id: DiscoveryGroupId,
151    pub source: DiscoveryCatalogSource,
152    pub name: String,
153    pub title: String,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub description: Option<String>,
156    pub status: DiscoveryItemStatus,
157    pub lifecycle: DiscoveryLifecycleState,
158    pub promotion: DiscoveryPromotionState,
159    pub cache_status: DiscoveryCacheStatus,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub schema: Option<DiscoverySchemaReference>,
162    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163    pub tags: Vec<String>,
164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
165    pub hints: Vec<String>,
166    #[serde(default)]
167    pub redaction: DiscoveryRedaction,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    #[serde(with = "time::serde::rfc3339::option")]
170    pub last_refreshed_at: Option<OffsetDateTime>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
174#[serde(rename_all = "camelCase")]
175pub struct DiscoveryCatalogGroup {
176    pub id: DiscoveryGroupId,
177    pub catalog_id: DiscoveryCatalogId,
178    pub source: DiscoveryCatalogSource,
179    pub title: String,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub description: Option<String>,
182    pub status: DiscoveryItemStatus,
183    #[serde(default)]
184    pub item_count: u64,
185    #[serde(default)]
186    pub hidden_item_count: u64,
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub items: Vec<DiscoveryCatalogItem>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    #[serde(with = "time::serde::rfc3339::option")]
191    pub last_refreshed_at: Option<OffsetDateTime>,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
195#[serde(rename_all = "camelCase")]
196pub struct DiscoveryCatalog {
197    pub id: DiscoveryCatalogId,
198    pub title: String,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub description: Option<String>,
201    #[serde(default, skip_serializing_if = "Vec::is_empty")]
202    pub groups: Vec<DiscoveryCatalogGroup>,
203    #[serde(default)]
204    pub hidden_item_count: u64,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    #[serde(with = "time::serde::rfc3339::option")]
207    pub built_at: Option<OffsetDateTime>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
211#[serde(rename_all = "camelCase")]
212pub struct DiscoveryPromotionRecord {
213    pub item_id: DiscoveryItemId,
214    pub group_id: DiscoveryGroupId,
215    pub thread_id: ThreadId,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub turn_id: Option<TurnId>,
218    pub promotion: DiscoveryPromotionState,
219    pub cache_status: DiscoveryCacheStatus,
220    #[serde(default)]
221    pub reused_count: u64,
222    #[serde(with = "time::serde::rfc3339")]
223    pub timestamp: OffsetDateTime,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(rename_all = "camelCase")]
228pub struct DiscoveryCatalogBuilt {
229    pub catalog_id: DiscoveryCatalogId,
230    pub group_count: u64,
231    pub item_count: u64,
232    pub hidden_item_count: u64,
233    #[serde(with = "time::serde::rfc3339")]
234    pub timestamp: OffsetDateTime,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct DiscoveryItemUpdated {
240    pub item: DiscoveryCatalogItem,
241    #[serde(with = "time::serde::rfc3339")]
242    pub timestamp: OffsetDateTime,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct DiscoveryAuthRequired {
248    pub item_id: DiscoveryItemId,
249    pub group_id: DiscoveryGroupId,
250    pub source: DiscoveryCatalogSource,
251    pub auth_state: DiscoveryAuthState,
252    #[serde(with = "time::serde::rfc3339")]
253    pub timestamp: OffsetDateTime,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct DiscoveryItemRead {
259    pub thread_id: ThreadId,
260    pub turn_id: TurnId,
261    pub item_id: DiscoveryItemId,
262    pub group_id: DiscoveryGroupId,
263    pub promoted: bool,
264    #[serde(with = "time::serde::rfc3339")]
265    pub timestamp: OffsetDateTime,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
269#[serde(rename_all = "camelCase")]
270pub struct DiscoveryItemPromoted {
271    pub record: DiscoveryPromotionRecord,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct DiscoveryPromotionReused {
277    pub record: DiscoveryPromotionRecord,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct DiscoveryWarmCacheHit {
283    pub record: DiscoveryPromotionRecord,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct DiscoveryPromotionExpired {
289    pub record: DiscoveryPromotionRecord,
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn source(kind: DiscoverySourceKind, id: &str) -> DiscoveryCatalogSource {
297        DiscoveryCatalogSource {
298            kind,
299            id: id.to_string(),
300            display_name: id.to_string(),
301            origin: Some(format!("fixture://{id}")),
302            auth_state: DiscoveryAuthState::NotRequired,
303            redaction: DiscoveryRedaction::none(),
304        }
305    }
306
307    fn item(kind: DiscoverySourceKind, id: &str) -> DiscoveryCatalogItem {
308        DiscoveryCatalogItem {
309            id: id.to_string(),
310            group_id: format!("group-{id}"),
311            source: source(kind, id),
312            name: id.to_string(),
313            title: id.to_string(),
314            description: Some(format!("{id} description")),
315            status: DiscoveryItemStatus::Available,
316            lifecycle: DiscoveryLifecycleState::Discovered,
317            promotion: DiscoveryPromotionState::NotPromoted,
318            cache_status: DiscoveryCacheStatus::Cold,
319            schema: None,
320            tags: vec!["fixture".to_string()],
321            hints: vec!["read before use".to_string()],
322            redaction: DiscoveryRedaction::none(),
323            last_refreshed_at: None,
324        }
325    }
326
327    #[test]
328    fn discovery_items_round_trip_for_all_source_families() {
329        let entries = vec![
330            item(DiscoverySourceKind::InternalTools, "tool:grep"),
331            item(DiscoverySourceKind::McpTools, "mcp:github/issues.search"),
332            item(DiscoverySourceKind::Skills, "skill:rust-clippy"),
333            item(DiscoverySourceKind::Commands, "command:project/test"),
334            item(DiscoverySourceKind::Plugins, "plugin:codex/review"),
335        ];
336
337        let value = serde_json::to_value(&entries).expect("serialize discovery entries");
338        let decoded: Vec<DiscoveryCatalogItem> =
339            serde_json::from_value(value).expect("deserialize discovery entries");
340        assert_eq!(decoded, entries);
341    }
342
343    #[test]
344    fn mcp_auth_metadata_redacts_secret_values() {
345        let entry = DiscoveryCatalogItem {
346            source: DiscoveryCatalogSource {
347                kind: DiscoverySourceKind::McpTools,
348                id: "github".to_string(),
349                display_name: "GitHub MCP".to_string(),
350                origin: Some("mcp://github".to_string()),
351                auth_state: DiscoveryAuthState::Required,
352                redaction: DiscoveryRedaction::secret_refs(["GITHUB_TOKEN"]),
353            },
354            status: DiscoveryItemStatus::AuthRequired,
355            redaction: DiscoveryRedaction {
356                redacted: true,
357                fields: vec!["env.GITHUB_TOKEN".to_string()],
358                secret_refs: vec!["GITHUB_TOKEN".to_string()],
359            },
360            ..item(DiscoverySourceKind::McpTools, "mcp:github/issues.search")
361        };
362
363        let value = serde_json::to_value(&entry).expect("serialize redacted entry");
364        let rendered = value.to_string();
365        assert!(rendered.contains("GITHUB_TOKEN"));
366        assert!(!rendered.contains("ghp_"));
367        assert_eq!(value["authState"], serde_json::Value::Null);
368        assert_eq!(value["source"]["authState"], "required");
369        assert_eq!(value["redaction"]["redacted"], true);
370    }
371
372    #[test]
373    fn schema_reference_uses_camel_case_fields() {
374        let mut entry = item(DiscoverySourceKind::InternalTools, "tool:grep");
375        entry.schema = Some(DiscoverySchemaReference {
376            format: DiscoverySchemaFormat::JsonSchema,
377            uri: "discovery/tools/builtin/grep.schema.json".to_string(),
378            content_hash: Some("sha256:abc".to_string()),
379            byte_count: Some(512),
380            redaction: DiscoveryRedaction::none(),
381        });
382
383        let value = serde_json::to_value(&entry).expect("serialize schema entry");
384        assert_eq!(value["schema"]["contentHash"], "sha256:abc");
385        assert_eq!(value["schema"]["byteCount"], 512);
386        let decoded: DiscoveryCatalogItem =
387            serde_json::from_value(value).expect("deserialize schema entry");
388        assert_eq!(decoded, entry);
389    }
390
391    #[test]
392    fn promotion_record_tracks_thread_lifecycle() {
393        let record = DiscoveryPromotionRecord {
394            item_id: "skill:roadmap-planning".to_string(),
395            group_id: "skills".to_string(),
396            thread_id: "thread-a".to_string(),
397            turn_id: Some("turn-b".to_string()),
398            promotion: DiscoveryPromotionState::WarmCacheHit,
399            cache_status: DiscoveryCacheStatus::Hit,
400            reused_count: 3,
401            timestamp: OffsetDateTime::UNIX_EPOCH,
402        };
403
404        let value = serde_json::to_value(&record).expect("serialize promotion");
405        assert_eq!(value["cacheStatus"], "hit");
406        assert_eq!(value["reusedCount"], 3);
407        let decoded: DiscoveryPromotionRecord =
408            serde_json::from_value(value).expect("deserialize promotion");
409        assert_eq!(decoded, record);
410    }
411}