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}