terraphim_session_analyzer/
models.rs

1use indexmap::IndexMap;
2use jiff::Timestamp;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fmt::{self, Display};
6use std::str::FromStr;
7
8/// Newtype wrappers for better type safety
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct SessionId(String);
11
12impl SessionId {
13    #[must_use]
14    #[allow(dead_code)]
15    pub fn new(id: String) -> Self {
16        Self(id)
17    }
18
19    #[must_use]
20    #[allow(dead_code)]
21    pub fn as_str(&self) -> &str {
22        &self.0
23    }
24}
25
26impl Display for SessionId {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        write!(f, "{}", self.0)
29    }
30}
31
32impl From<String> for SessionId {
33    fn from(id: String) -> Self {
34        Self(id)
35    }
36}
37
38impl From<&str> for SessionId {
39    fn from(id: &str) -> Self {
40        Self(id.to_string())
41    }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
45pub struct AgentType(String);
46
47impl AgentType {
48    #[must_use]
49    #[allow(dead_code)]
50    pub fn new(agent_type: String) -> Self {
51        Self(agent_type)
52    }
53
54    #[must_use]
55    #[allow(dead_code)]
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59}
60
61impl Display for AgentType {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        write!(f, "{}", self.0)
64    }
65}
66
67impl From<String> for AgentType {
68    fn from(agent_type: String) -> Self {
69        Self(agent_type)
70    }
71}
72
73impl From<&str> for AgentType {
74    fn from(agent_type: &str) -> Self {
75        Self(agent_type.to_string())
76    }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
80pub struct MessageId(String);
81
82impl MessageId {
83    #[must_use]
84    #[allow(dead_code)]
85    pub fn new(id: String) -> Self {
86        Self(id)
87    }
88
89    #[must_use]
90    #[allow(dead_code)]
91    pub fn as_str(&self) -> &str {
92        &self.0
93    }
94}
95
96impl Display for MessageId {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        write!(f, "{}", self.0)
99    }
100}
101
102impl From<String> for MessageId {
103    fn from(id: String) -> Self {
104        Self(id)
105    }
106}
107
108impl From<&str> for MessageId {
109    fn from(id: &str) -> Self {
110        Self(id.to_string())
111    }
112}
113
114impl AsRef<str> for SessionId {
115    fn as_ref(&self) -> &str {
116        &self.0
117    }
118}
119
120impl AsRef<str> for AgentType {
121    fn as_ref(&self) -> &str {
122        &self.0
123    }
124}
125
126impl AsRef<str> for MessageId {
127    fn as_ref(&self) -> &str {
128        &self.0
129    }
130}
131
132/// Parse JSONL session entries from Claude Code
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct SessionEntry {
136    pub uuid: String,
137    pub parent_uuid: Option<String>,
138    pub session_id: String,
139    pub timestamp: String,
140    pub user_type: String,
141    pub message: Message,
142    #[serde(rename = "type")]
143    pub entry_type: String,
144    pub cwd: Option<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(untagged)]
149pub enum Message {
150    User {
151        role: String,
152        content: String,
153    },
154    Assistant {
155        role: String,
156        content: Vec<ContentBlock>,
157        #[serde(default)]
158        id: Option<String>,
159        #[serde(default)]
160        model: Option<String>,
161    },
162    ToolResult {
163        role: String,
164        content: Vec<ToolResultContent>,
165    },
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(tag = "type", rename_all = "snake_case")]
170pub enum ContentBlock {
171    Text {
172        text: String,
173    },
174    ToolUse {
175        id: String,
176        name: String,
177        input: serde_json::Value,
178    },
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct ToolResultContent {
183    pub tool_use_id: String,
184    #[serde(rename = "type")]
185    pub content_type: String,
186    pub content: String,
187}
188
189/// Agent invocation tracking
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct AgentInvocation {
192    pub timestamp: Timestamp,
193    pub agent_type: String,
194    pub task_description: String,
195    pub prompt: String,
196    pub files_modified: Vec<String>,
197    pub tools_used: Vec<String>,
198    pub duration_ms: Option<u64>,
199    pub parent_message_id: String,
200    pub session_id: String,
201}
202
203/// File operations extracted from tool uses
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct FileOperation {
206    pub timestamp: Timestamp,
207    pub operation: FileOpType,
208    pub file_path: String,
209    pub agent_context: Option<String>,
210    pub session_id: String,
211    pub message_id: String,
212}
213
214/// Tool invocation extracted from Bash commands
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct ToolInvocation {
217    pub timestamp: Timestamp,
218    pub tool_name: String,
219    pub tool_category: ToolCategory,
220    pub command_line: String,
221    pub arguments: Vec<String>,
222    pub flags: HashMap<String, String>,
223    pub exit_code: Option<i32>,
224    pub agent_context: Option<String>,
225    pub session_id: String,
226    pub message_id: String,
227}
228
229/// Category of tool being used
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
231pub enum ToolCategory {
232    PackageManager,
233    BuildTool,
234    Testing,
235    Linting,
236    Git,
237    CloudDeploy,
238    Database,
239    Other(String),
240}
241
242impl ToolCategory {
243    /// Parse a string category into ToolCategory
244    /// Used in parser for converting string categories
245    #[must_use]
246    #[allow(dead_code)]
247    pub fn from_string(s: &str) -> Self {
248        match s {
249            "PackageManager" => ToolCategory::PackageManager,
250            "BuildTool" => ToolCategory::BuildTool,
251            "Testing" => ToolCategory::Testing,
252            "Linting" => ToolCategory::Linting,
253            "Git" => ToolCategory::Git,
254            "CloudDeploy" => ToolCategory::CloudDeploy,
255            "Database" => ToolCategory::Database,
256            _ => ToolCategory::Other(s.to_string()),
257        }
258    }
259}
260
261/// Statistics for a specific tool
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ToolStatistics {
264    pub tool_name: String,
265    pub category: ToolCategory,
266    pub total_invocations: u32,
267    pub agents_using: Vec<String>,
268    pub success_count: u32,
269    pub failure_count: u32,
270    pub first_seen: Timestamp,
271    pub last_seen: Timestamp,
272    pub command_patterns: Vec<String>,
273    pub sessions: Vec<String>,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub enum FileOpType {
278    Read,
279    Write,
280    Edit,
281    MultiEdit,
282    Delete,
283    Glob,
284    Grep,
285}
286
287impl FromStr for FileOpType {
288    type Err = anyhow::Error;
289
290    fn from_str(s: &str) -> Result<Self, Self::Err> {
291        match s {
292            "Read" => Ok(FileOpType::Read),
293            "Write" => Ok(FileOpType::Write),
294            "Edit" => Ok(FileOpType::Edit),
295            "MultiEdit" => Ok(FileOpType::MultiEdit),
296            "Delete" => Ok(FileOpType::Delete),
297            "Glob" => Ok(FileOpType::Glob),
298            "Grep" => Ok(FileOpType::Grep),
299            _ => Err(anyhow::anyhow!("Unknown file operation type: {s}")),
300        }
301    }
302}
303
304/// Analysis results for a session
305#[derive(Debug, Serialize, Deserialize)]
306pub struct SessionAnalysis {
307    pub session_id: String,
308    pub project_path: String,
309    pub start_time: Timestamp,
310    pub end_time: Timestamp,
311    pub duration_ms: u64,
312    pub agents: Vec<AgentInvocation>,
313    pub file_operations: Vec<FileOperation>,
314    pub file_to_agents: IndexMap<String, Vec<AgentAttribution>>,
315    pub agent_stats: IndexMap<String, AgentStatistics>,
316    pub collaboration_patterns: Vec<CollaborationPattern>,
317}
318
319/// Attribution of a file to an agent
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct AgentAttribution {
322    pub agent_type: String,
323    pub contribution_percent: f32,
324    pub confidence_score: f32,
325    pub operations: Vec<String>,
326    pub first_interaction: Timestamp,
327    pub last_interaction: Timestamp,
328}
329
330/// Statistics for an individual agent
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct AgentStatistics {
333    pub agent_type: String,
334    pub total_invocations: u32,
335    pub total_duration_ms: u64,
336    pub files_touched: u32,
337    pub tools_used: Vec<String>,
338    pub first_seen: Timestamp,
339    pub last_seen: Timestamp,
340}
341
342/// Collaboration patterns between agents
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct CollaborationPattern {
345    pub pattern_type: String,
346    pub agents: Vec<String>,
347    pub description: String,
348    pub frequency: u32,
349    pub confidence: f32,
350}
351
352/// Correlation between agents and tools
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct AgentToolCorrelation {
355    pub agent_type: String,
356    pub tool_name: String,
357    pub usage_count: u32,
358    pub success_rate: f32,
359    pub average_invocations_per_session: f32,
360}
361
362/// Complete tool usage analysis
363#[derive(Debug, Serialize, Deserialize)]
364pub struct ToolAnalysis {
365    pub session_id: String,
366    pub total_tool_invocations: u32,
367    pub tool_statistics: IndexMap<String, ToolStatistics>,
368    pub agent_tool_correlations: Vec<AgentToolCorrelation>,
369    pub tool_chains: Vec<ToolChain>,
370    pub category_breakdown: IndexMap<ToolCategory, u32>,
371}
372
373/// Sequence of tools used together
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct ToolChain {
376    pub tools: Vec<String>,
377    pub frequency: u32,
378    pub average_time_between_ms: u64,
379    pub typical_agent: Option<String>,
380    pub success_rate: f32,
381}
382
383/// Configuration for the analyzer
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct AnalyzerConfig {
386    pub session_dirs: Vec<String>,
387    pub agent_confidence_threshold: f32,
388    pub file_attribution_window_ms: u64,
389    pub exclude_patterns: Vec<String>,
390}
391
392impl Default for AnalyzerConfig {
393    fn default() -> Self {
394        Self {
395            session_dirs: vec![],
396            agent_confidence_threshold: 0.7,
397            file_attribution_window_ms: 300_000, // 5 minutes
398            exclude_patterns: vec![
399                "node_modules/".to_string(),
400                "target/".to_string(),
401                ".git/".to_string(),
402            ],
403        }
404    }
405}
406
407/// Parse an ISO 8601 timestamp string into a `jiff::Timestamp`
408///
409/// # Errors
410///
411/// Returns an error if the timestamp string is malformed or cannot be parsed
412pub fn parse_timestamp(timestamp_str: &str) -> Result<Timestamp, anyhow::Error> {
413    // Handle ISO 8601 timestamps from Claude session logs
414    Timestamp::from_str(timestamp_str)
415        .map_err(|e| anyhow::anyhow!("Failed to parse timestamp '{timestamp_str}': {e}"))
416}
417
418/// Helper to extract file path from various tool inputs
419#[must_use]
420pub fn extract_file_path(input: &serde_json::Value) -> Option<String> {
421    // Try different field names that might contain file paths
422    for field in &["file_path", "path", "pattern"] {
423        if let Some(path) = input.get(field).and_then(|v| v.as_str()) {
424            return Some(path.to_string());
425        }
426    }
427
428    // For MultiEdit, check the edits array
429    if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
430        if !edits.is_empty() {
431            if let Some(file_path) = input.get("file_path").and_then(|v| v.as_str()) {
432                return Some(file_path.to_string());
433            }
434        }
435    }
436
437    None
438}
439
440/// Agent type utilities
441/// Used in integration tests and public API
442#[allow(dead_code)]
443#[must_use]
444pub fn normalize_agent_name(agent_type: &str) -> String {
445    agent_type.to_lowercase().replace(['-', ' '], "_")
446}
447
448/// Used in integration tests and public API
449#[allow(dead_code)]
450#[must_use]
451pub fn get_agent_category(agent_type: &str) -> &'static str {
452    match agent_type {
453        "architect" | "backend-architect" | "frontend-developer" => "architecture",
454        "developer" | "rapid-prototyper" => "development",
455        "rust-performance-expert" | "rust-code-reviewer" => "rust-expert",
456        "debugger" | "test-writer-fixer" => "testing",
457        "technical-writer" => "documentation",
458        "devops-automator" | "overseer" => "operations",
459        "general-purpose" => "general",
460        _ => "other",
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_parse_timestamp() {
470        let timestamp_str = "2025-10-01T09:05:21.902Z";
471        let result = parse_timestamp(timestamp_str);
472        assert!(result.is_ok());
473    }
474
475    #[test]
476    fn test_newtype_wrappers() {
477        // Test SessionId
478        let session_id = SessionId::new("test-session".to_string());
479        assert_eq!(session_id.as_str(), "test-session");
480        assert_eq!(session_id.to_string(), "test-session");
481        assert_eq!(session_id.as_ref(), "test-session");
482
483        let session_id_from_str: SessionId = "another-session".into();
484        assert_eq!(session_id_from_str.as_str(), "another-session");
485
486        // Test AgentType
487        let agent_type = AgentType::new("architect".to_string());
488        assert_eq!(agent_type.as_str(), "architect");
489        assert_eq!(agent_type.to_string(), "architect");
490
491        // Test MessageId
492        let message_id = MessageId::new("msg-123".to_string());
493        assert_eq!(message_id.as_str(), "msg-123");
494        assert_eq!(message_id.to_string(), "msg-123");
495    }
496
497    #[test]
498    fn test_extract_file_path() {
499        let input = serde_json::json!({
500            "file_path": "/path/to/file.rs",
501            "description": "Edit file"
502        });
503
504        let path = extract_file_path(&input);
505        assert_eq!(path, Some("/path/to/file.rs".to_string()));
506    }
507
508    #[test]
509    fn test_normalize_agent_name() {
510        assert_eq!(
511            normalize_agent_name("rust-performance-expert"),
512            "rust_performance_expert"
513        );
514        assert_eq!(
515            normalize_agent_name("backend-architect"),
516            "backend_architect"
517        );
518    }
519
520    mod proptest_tests {
521        use super::*;
522        use proptest::prelude::*;
523
524        proptest! {
525            #[test]
526            fn test_normalize_agent_name_properties(
527                input in "[a-zA-Z0-9 -]{1,50}"
528            ) {
529                let result = normalize_agent_name(&input);
530
531                // Property 1: Result should only contain lowercase letters, numbers, and underscores
532                prop_assert!(result.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'));
533
534                // Property 2: Result should not be empty if input was not empty
535                if !input.trim().is_empty() {
536                    prop_assert!(!result.is_empty());
537                }
538            }
539
540            #[test]
541            fn test_parse_timestamp_properties(
542                year in 2020u16..2030,
543                month in 1u8..=12,
544                day in 1u8..=28, // Safe range to avoid month-specific issues
545                hour in 0u8..=23,
546                minute in 0u8..=59,
547                second in 0u8..=59,
548                millis in 0u16..1000
549            ) {
550                let timestamp_str = format!(
551                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
552                    year, month, day, hour, minute, second, millis
553                );
554
555                let result = parse_timestamp(&timestamp_str);
556
557                // Property: Valid ISO 8601 timestamps should always parse successfully
558                prop_assert!(result.is_ok(), "Failed to parse valid timestamp: {}", timestamp_str);
559
560                if let Ok(parsed) = result {
561                    // Property: Parsed timestamp should roundtrip correctly
562                    let reformatted = parsed.to_string();
563                    prop_assert!(reformatted.starts_with(&year.to_string()));
564                }
565            }
566
567            #[test]
568            fn test_extract_file_path_properties(
569                file_path in r"[a-zA-Z0-9_./\-]{1,100}"
570            ) {
571                let input = serde_json::json!({
572                    "file_path": file_path
573                });
574
575                let result = extract_file_path(&input);
576
577                // Property: If file_path field exists, it should be extracted
578                prop_assert_eq!(result, Some(file_path.clone()));
579
580                // Test with different field names
581                let input_path = serde_json::json!({
582                    "path": file_path
583                });
584                let result_path = extract_file_path(&input_path);
585                prop_assert_eq!(result_path, Some(file_path.clone()));
586            }
587
588            #[test]
589            fn test_newtype_wrapper_roundtrip(
590                session_id in "[a-zA-Z0-9-]{10,50}",
591                agent_type in "[a-zA-Z0-9-_]{3,30}",
592                message_id in "[a-zA-Z0-9-]{10,50}"
593            ) {
594                // Test SessionId roundtrip
595                let session = SessionId::new(session_id.clone());
596                prop_assert_eq!(session.as_str(), &session_id);
597                prop_assert_eq!(session.to_string(), session_id);
598
599                // Test AgentType roundtrip
600                let agent = AgentType::new(agent_type.clone());
601                prop_assert_eq!(agent.as_str(), &agent_type);
602                prop_assert_eq!(agent.to_string(), agent_type);
603
604                // Test MessageId roundtrip
605                let message = MessageId::new(message_id.clone());
606                prop_assert_eq!(message.as_str(), &message_id);
607                prop_assert_eq!(message.to_string(), message_id);
608            }
609        }
610    }
611}