Skip to main content

coding_agent_search/model/
types.rs

1//! Normalized entity structs.
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6/// Roles seen across source agents.
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
8pub enum MessageRole {
9    User,
10    Agent,
11    Tool,
12    System,
13    Other(String),
14}
15
16impl std::fmt::Display for MessageRole {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            MessageRole::User => write!(f, "User"),
20            MessageRole::Agent => write!(f, "Agent"),
21            MessageRole::Tool => write!(f, "Tool"),
22            MessageRole::System => write!(f, "System"),
23            MessageRole::Other(s) => write!(f, "{}", s),
24        }
25    }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Agent {
30    pub id: Option<i64>,
31    pub slug: String,
32    pub name: String,
33    pub version: Option<String>,
34    pub kind: AgentKind,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub enum AgentKind {
39    Cli,
40    VsCode,
41    Hybrid,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Workspace {
46    pub id: Option<i64>,
47    pub path: PathBuf,
48    pub display_name: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Conversation {
53    pub id: Option<i64>,
54    pub agent_slug: String,
55    pub workspace: Option<PathBuf>,
56    pub external_id: Option<String>,
57    pub title: Option<String>,
58    pub source_path: PathBuf,
59    pub started_at: Option<i64>,
60    pub ended_at: Option<i64>,
61    pub approx_tokens: Option<i64>,
62    pub metadata_json: serde_json::Value,
63    pub messages: Vec<Message>,
64    /// Source ID for provenance tracking (e.g., "local", "work-laptop").
65    /// Defaults to "local" for backward compatibility.
66    #[serde(default = "default_source_id")]
67    pub source_id: String,
68    /// Origin host label for remote sources.
69    #[serde(default)]
70    pub origin_host: Option<String>,
71}
72
73fn default_source_id() -> String {
74    "local".to_string()
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct Message {
79    pub id: Option<i64>,
80    pub idx: i64,
81    pub role: MessageRole,
82    pub author: Option<String>,
83    pub created_at: Option<i64>,
84    pub content: String,
85    pub extra_json: serde_json::Value,
86    pub snippets: Vec<Snippet>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Snippet {
91    pub id: Option<i64>,
92    pub file_path: Option<PathBuf>,
93    pub start_line: Option<i64>,
94    pub end_line: Option<i64>,
95    pub language: Option<String>,
96    pub snippet_text: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct Tag {
101    pub id: Option<i64>,
102    pub name: String,
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use serde_json::{from_value, json, to_value};
109
110    fn message_fixture(content: impl Into<String>) -> Message {
111        Message {
112            id: None,
113            idx: 0,
114            role: MessageRole::User,
115            author: None,
116            created_at: None,
117            content: content.into(),
118            extra_json: json!(null),
119            snippets: vec![],
120        }
121    }
122
123    fn conversation_fixture(agent_slug: &str, source_path: &str) -> Conversation {
124        Conversation {
125            id: None,
126            agent_slug: agent_slug.to_string(),
127            workspace: None,
128            external_id: None,
129            title: None,
130            source_path: PathBuf::from(source_path),
131            started_at: None,
132            ended_at: None,
133            approx_tokens: None,
134            metadata_json: json!(null),
135            messages: vec![],
136            source_id: "local".to_string(),
137            origin_host: None,
138        }
139    }
140
141    // =========================
142    // MessageRole Tests
143    // =========================
144
145    #[test]
146    fn message_role_display() {
147        let cases = [
148            (MessageRole::User, "User"),
149            (MessageRole::Agent, "Agent"),
150            (MessageRole::Tool, "Tool"),
151            (MessageRole::System, "System"),
152            (MessageRole::Other("Custom".to_string()), "Custom"),
153            (MessageRole::Other("".to_string()), ""),
154            (MessageRole::Other("日本語".to_string()), "日本語"),
155        ];
156
157        for (role, expected_display) in cases {
158            let actual_display = role.to_string();
159            assert_eq!(actual_display, expected_display, "role: {role:?}");
160        }
161    }
162
163    #[test]
164    fn message_role_serde_roundtrip() {
165        let roles = vec![
166            MessageRole::User,
167            MessageRole::Agent,
168            MessageRole::Tool,
169            MessageRole::System,
170            MessageRole::Other("CustomRole".to_string()),
171        ];
172
173        for role in roles {
174            let serialized = to_value(&role).unwrap();
175            let deserialized: MessageRole = from_value(serialized).unwrap();
176            assert_eq!(role, deserialized);
177        }
178    }
179
180    #[test]
181    fn message_role_equality() {
182        assert_eq!(MessageRole::User, MessageRole::User);
183        assert_ne!(MessageRole::User, MessageRole::Agent);
184        assert_eq!(
185            MessageRole::Other("x".to_string()),
186            MessageRole::Other("x".to_string())
187        );
188        assert_ne!(
189            MessageRole::Other("x".to_string()),
190            MessageRole::Other("y".to_string())
191        );
192    }
193
194    // =========================
195    // AgentKind Tests
196    // =========================
197
198    #[test]
199    fn agent_kind_serde_roundtrip() {
200        let kinds = vec![AgentKind::Cli, AgentKind::VsCode, AgentKind::Hybrid];
201
202        for kind in kinds {
203            let serialized = to_value(&kind).unwrap();
204            let deserialized: AgentKind = from_value(serialized).unwrap();
205            assert_eq!(kind, deserialized);
206        }
207    }
208
209    #[test]
210    fn agent_kind_equality() {
211        assert_eq!(AgentKind::Cli, AgentKind::Cli);
212        assert_ne!(AgentKind::Cli, AgentKind::VsCode);
213        assert_ne!(AgentKind::VsCode, AgentKind::Hybrid);
214    }
215
216    // =========================
217    // Agent Tests
218    // =========================
219
220    #[test]
221    fn agent_serde_roundtrip() {
222        let agent = Agent {
223            id: Some(42),
224            slug: "claude-code".to_string(),
225            name: "Claude Code".to_string(),
226            version: Some("1.0.0".to_string()),
227            kind: AgentKind::Cli,
228        };
229
230        let json = serde_json::to_string(&agent).unwrap();
231        let deserialized: Agent = serde_json::from_str(&json).unwrap();
232
233        assert_eq!(deserialized.id, Some(42));
234        assert_eq!(deserialized.slug, "claude-code");
235        assert_eq!(deserialized.name, "Claude Code");
236        assert_eq!(deserialized.version, Some("1.0.0".to_string()));
237        assert_eq!(deserialized.kind, AgentKind::Cli);
238    }
239
240    #[test]
241    fn agent_with_none_fields() {
242        let agent = Agent {
243            id: None,
244            slug: "test".to_string(),
245            name: "Test".to_string(),
246            version: None,
247            kind: AgentKind::VsCode,
248        };
249
250        let json = serde_json::to_string(&agent).unwrap();
251        let deserialized: Agent = serde_json::from_str(&json).unwrap();
252
253        assert!(deserialized.id.is_none());
254        assert!(deserialized.version.is_none());
255    }
256
257    // =========================
258    // Workspace Tests
259    // =========================
260
261    #[test]
262    fn workspace_serde_roundtrip() {
263        let workspace = Workspace {
264            id: Some(1),
265            path: PathBuf::from("/home/user/project"),
266            display_name: Some("My Project".to_string()),
267        };
268
269        let json = serde_json::to_string(&workspace).unwrap();
270        let deserialized: Workspace = serde_json::from_str(&json).unwrap();
271
272        assert_eq!(deserialized.id, Some(1));
273        assert_eq!(deserialized.path, PathBuf::from("/home/user/project"));
274        assert_eq!(deserialized.display_name, Some("My Project".to_string()));
275    }
276
277    #[test]
278    fn workspace_with_unicode_path() {
279        let workspace = Workspace {
280            id: None,
281            path: PathBuf::from("/home/用户/プロジェクト"),
282            display_name: Some("日本語プロジェクト".to_string()),
283        };
284
285        let json = serde_json::to_string(&workspace).unwrap();
286        let deserialized: Workspace = serde_json::from_str(&json).unwrap();
287
288        assert_eq!(deserialized.path, PathBuf::from("/home/用户/プロジェクト"));
289        assert_eq!(
290            deserialized.display_name,
291            Some("日本語プロジェクト".to_string())
292        );
293    }
294
295    // =========================
296    // Tag Tests
297    // =========================
298
299    #[test]
300    fn tag_serde_roundtrip() {
301        let tag = Tag {
302            id: Some(100),
303            name: "important".to_string(),
304        };
305
306        let json = serde_json::to_string(&tag).unwrap();
307        let deserialized: Tag = serde_json::from_str(&json).unwrap();
308
309        assert_eq!(deserialized.id, Some(100));
310        assert_eq!(deserialized.name, "important");
311    }
312
313    #[test]
314    fn tag_with_empty_name() {
315        let tag = Tag {
316            id: None,
317            name: "".to_string(),
318        };
319
320        let json = serde_json::to_string(&tag).unwrap();
321        let deserialized: Tag = serde_json::from_str(&json).unwrap();
322
323        assert_eq!(deserialized.name, "");
324    }
325
326    // =========================
327    // Snippet Tests
328    // =========================
329
330    #[test]
331    fn snippet_serde_roundtrip() {
332        let snippet = Snippet {
333            id: Some(1),
334            file_path: Some(PathBuf::from("src/main.rs")),
335            start_line: Some(10),
336            end_line: Some(20),
337            language: Some("rust".to_string()),
338            snippet_text: Some("fn main() {}".to_string()),
339        };
340
341        let json = serde_json::to_string(&snippet).unwrap();
342        let deserialized: Snippet = serde_json::from_str(&json).unwrap();
343
344        assert_eq!(deserialized.id, Some(1));
345        assert_eq!(deserialized.file_path, Some(PathBuf::from("src/main.rs")));
346        assert_eq!(deserialized.start_line, Some(10));
347        assert_eq!(deserialized.end_line, Some(20));
348        assert_eq!(deserialized.language, Some("rust".to_string()));
349        assert_eq!(deserialized.snippet_text, Some("fn main() {}".to_string()));
350    }
351
352    #[test]
353    fn snippet_all_none() {
354        let snippet = Snippet {
355            id: None,
356            file_path: None,
357            start_line: None,
358            end_line: None,
359            language: None,
360            snippet_text: None,
361        };
362
363        let json = serde_json::to_string(&snippet).unwrap();
364        let deserialized: Snippet = serde_json::from_str(&json).unwrap();
365
366        assert!(deserialized.id.is_none());
367        assert!(deserialized.file_path.is_none());
368        assert!(deserialized.start_line.is_none());
369        assert!(deserialized.end_line.is_none());
370        assert!(deserialized.language.is_none());
371        assert!(deserialized.snippet_text.is_none());
372    }
373
374    // =========================
375    // Message Tests
376    // =========================
377
378    #[test]
379    fn message_serde_roundtrip() {
380        let message = Message {
381            id: Some(42),
382            idx: 0,
383            role: MessageRole::User,
384            author: Some("human".to_string()),
385            created_at: Some(1700000000000),
386            content: "Hello, world!".to_string(),
387            extra_json: json!({"key": "value"}),
388            snippets: vec![],
389        };
390
391        let json = serde_json::to_string(&message).unwrap();
392        let deserialized: Message = serde_json::from_str(&json).unwrap();
393
394        assert_eq!(deserialized.id, Some(42));
395        assert_eq!(deserialized.idx, 0);
396        assert_eq!(deserialized.role, MessageRole::User);
397        assert_eq!(deserialized.author, Some("human".to_string()));
398        assert_eq!(deserialized.created_at, Some(1700000000000));
399        assert_eq!(deserialized.content, "Hello, world!");
400        assert_eq!(deserialized.extra_json, json!({"key": "value"}));
401        assert!(deserialized.snippets.is_empty());
402    }
403
404    #[test]
405    fn message_with_snippets() {
406        let snippet = Snippet {
407            id: None,
408            file_path: Some(PathBuf::from("test.rs")),
409            start_line: Some(1),
410            end_line: Some(5),
411            language: Some("rust".to_string()),
412            snippet_text: Some("code".to_string()),
413        };
414
415        let mut message = message_fixture("Here's some code");
416        message.idx = 1;
417        message.role = MessageRole::Agent;
418        message.snippets = vec![snippet];
419
420        let json = serde_json::to_string(&message).unwrap();
421        let deserialized: Message = serde_json::from_str(&json).unwrap();
422
423        assert_eq!(deserialized.snippets.len(), 1);
424        assert_eq!(deserialized.snippets[0].language, Some("rust".to_string()));
425    }
426
427    #[test]
428    fn message_with_unicode_content() {
429        let mut message = message_fixture("こんにちは世界!🌍");
430        message.author = Some("ユーザー".to_string());
431        message.extra_json = json!({"emoji": "🎉"});
432
433        let json = serde_json::to_string(&message).unwrap();
434        let deserialized: Message = serde_json::from_str(&json).unwrap();
435
436        assert_eq!(deserialized.content, "こんにちは世界!🌍");
437        assert_eq!(deserialized.author, Some("ユーザー".to_string()));
438    }
439
440    // =========================
441    // Conversation Tests
442    // =========================
443
444    #[test]
445    fn conversation_serde_roundtrip() {
446        let conversation = Conversation {
447            id: Some(1),
448            agent_slug: "claude-code".to_string(),
449            workspace: Some(PathBuf::from("/project")),
450            external_id: Some("ext-123".to_string()),
451            title: Some("Test Conversation".to_string()),
452            source_path: PathBuf::from("/path/to/session.jsonl"),
453            started_at: Some(1700000000000),
454            ended_at: Some(1700003600000),
455            approx_tokens: Some(1000),
456            metadata_json: json!({"model": "claude-3"}),
457            messages: vec![],
458            source_id: "local".to_string(),
459            origin_host: None,
460        };
461
462        let json = serde_json::to_string(&conversation).unwrap();
463        let deserialized: Conversation = serde_json::from_str(&json).unwrap();
464
465        assert_eq!(deserialized.id, Some(1));
466        assert_eq!(deserialized.agent_slug, "claude-code");
467        assert_eq!(deserialized.workspace, Some(PathBuf::from("/project")));
468        assert_eq!(deserialized.external_id, Some("ext-123".to_string()));
469        assert_eq!(deserialized.title, Some("Test Conversation".to_string()));
470        assert_eq!(
471            deserialized.source_path,
472            PathBuf::from("/path/to/session.jsonl")
473        );
474        assert_eq!(deserialized.started_at, Some(1700000000000));
475        assert_eq!(deserialized.ended_at, Some(1700003600000));
476        assert_eq!(deserialized.approx_tokens, Some(1000));
477        assert_eq!(deserialized.source_id, "local");
478        assert!(deserialized.origin_host.is_none());
479    }
480
481    #[test]
482    fn conversation_source_id_default() {
483        // Test that source_id defaults to "local" when not present
484        let json = json!({
485            "agent_slug": "test",
486            "source_path": "/test.jsonl",
487            "metadata_json": {},
488            "messages": []
489        });
490
491        let conversation: Conversation = from_value(json).unwrap();
492        assert_eq!(conversation.source_id, "local");
493    }
494
495    #[test]
496    fn conversation_with_remote_source() {
497        let mut conversation = conversation_fixture("codex", "/remote/session.jsonl");
498        conversation.source_id = "work-laptop".to_string();
499        conversation.origin_host = Some("laptop.local".to_string());
500
501        let json = serde_json::to_string(&conversation).unwrap();
502        let deserialized: Conversation = serde_json::from_str(&json).unwrap();
503
504        assert_eq!(deserialized.source_id, "work-laptop");
505        assert_eq!(deserialized.origin_host, Some("laptop.local".to_string()));
506    }
507
508    #[test]
509    fn conversation_with_messages() {
510        let mut conversation = conversation_fixture("test", "/test.jsonl");
511        conversation.messages = vec![message_fixture("Hello")];
512
513        let json = serde_json::to_string(&conversation).unwrap();
514        let deserialized: Conversation = serde_json::from_str(&json).unwrap();
515
516        assert_eq!(deserialized.messages.len(), 1);
517        assert_eq!(deserialized.messages[0].content, "Hello");
518    }
519
520    // =========================
521    // Edge Cases
522    // =========================
523
524    #[test]
525    fn empty_strings_are_valid() {
526        let tag = Tag {
527            id: None,
528            name: "".to_string(),
529        };
530        let agent = Agent {
531            id: None,
532            slug: "".to_string(),
533            name: "".to_string(),
534            version: Some("".to_string()),
535            kind: AgentKind::Cli,
536        };
537
538        // Both should serialize/deserialize without error
539        let tag_json = serde_json::to_string(&tag).unwrap();
540        let _: Tag = serde_json::from_str(&tag_json).unwrap();
541
542        let agent_json = serde_json::to_string(&agent).unwrap();
543        let _: Agent = serde_json::from_str(&agent_json).unwrap();
544    }
545
546    #[test]
547    fn large_content_strings() {
548        let large_content = "x".repeat(100_000);
549        let mut message = message_fixture(large_content.clone());
550        message.role = MessageRole::Agent;
551
552        let json = serde_json::to_string(&message).unwrap();
553        let deserialized: Message = serde_json::from_str(&json).unwrap();
554
555        assert_eq!(deserialized.content.len(), 100_000);
556    }
557
558    #[test]
559    fn special_characters_in_strings() {
560        let content = "Hello\nWorld\t\"quoted\"\r\nbackslash\\end";
561        let message = message_fixture(content);
562
563        let json = serde_json::to_string(&message).unwrap();
564        let deserialized: Message = serde_json::from_str(&json).unwrap();
565
566        assert_eq!(deserialized.content, content);
567    }
568
569    #[test]
570    fn negative_line_numbers() {
571        // While semantically odd, the type allows negative numbers
572        let snippet = Snippet {
573            id: Some(-1),
574            file_path: None,
575            start_line: Some(-10),
576            end_line: Some(-5),
577            language: None,
578            snippet_text: None,
579        };
580
581        let json = serde_json::to_string(&snippet).unwrap();
582        let deserialized: Snippet = serde_json::from_str(&json).unwrap();
583
584        assert_eq!(deserialized.start_line, Some(-10));
585        assert_eq!(deserialized.end_line, Some(-5));
586    }
587
588    #[test]
589    fn complex_metadata_json() {
590        let metadata = json!({
591            "nested": {
592                "array": [1, 2, 3],
593                "object": {"key": "value"},
594                "null": null,
595                "bool": true,
596                "number": 42.5
597            }
598        });
599
600        let mut conversation = conversation_fixture("test", "/test.jsonl");
601        conversation.metadata_json = metadata.clone();
602
603        let json = serde_json::to_string(&conversation).unwrap();
604        let deserialized: Conversation = serde_json::from_str(&json).unwrap();
605
606        assert_eq!(deserialized.metadata_json, metadata);
607    }
608}