Skip to main content

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    Thinking {
180        thinking: String,
181    },
182    ServerToolUse {
183        id: String,
184        name: String,
185        input: serde_json::Value,
186    },
187    AdvisorToolResult {
188        tool_use_id: String,
189        content: serde_json::Value,
190    },
191    #[serde(other)]
192    Unknown,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct ToolResultContent {
197    pub tool_use_id: String,
198    #[serde(rename = "type")]
199    pub content_type: String,
200    pub content: String,
201}
202
203/// Agent invocation tracking
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct AgentInvocation {
206    pub timestamp: Timestamp,
207    pub agent_type: String,
208    pub task_description: String,
209    pub prompt: String,
210    pub files_modified: Vec<String>,
211    pub tools_used: Vec<String>,
212    pub duration_ms: Option<u64>,
213    pub parent_message_id: String,
214    pub session_id: String,
215}
216
217/// File operations extracted from tool uses
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct FileOperation {
220    pub timestamp: Timestamp,
221    pub operation: FileOpType,
222    pub file_path: String,
223    pub agent_context: Option<String>,
224    pub session_id: String,
225    pub message_id: String,
226}
227
228/// Tool invocation extracted from Bash commands
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct ToolInvocation {
231    pub timestamp: Timestamp,
232    pub tool_name: String,
233    pub tool_category: ToolCategory,
234    pub command_line: String,
235    pub arguments: Vec<String>,
236    pub flags: HashMap<String, String>,
237    pub exit_code: Option<i32>,
238    pub agent_context: Option<String>,
239    pub session_id: String,
240    pub message_id: String,
241}
242
243/// Category of tool being used
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
245pub enum ToolCategory {
246    PackageManager,
247    BuildTool,
248    Testing,
249    Linting,
250    Git,
251    CloudDeploy,
252    Database,
253    Other(String),
254}
255
256impl ToolCategory {
257    /// Parse a string category into ToolCategory
258    /// Used in parser for converting string categories
259    #[must_use]
260    #[allow(dead_code)]
261    pub fn from_string(s: &str) -> Self {
262        match s {
263            "PackageManager" => ToolCategory::PackageManager,
264            "BuildTool" => ToolCategory::BuildTool,
265            "Testing" => ToolCategory::Testing,
266            "Linting" => ToolCategory::Linting,
267            "Git" => ToolCategory::Git,
268            "CloudDeploy" => ToolCategory::CloudDeploy,
269            "Database" => ToolCategory::Database,
270            _ => ToolCategory::Other(s.to_string()),
271        }
272    }
273}
274
275/// Statistics for a specific tool
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct ToolStatistics {
278    pub tool_name: String,
279    pub category: ToolCategory,
280    pub total_invocations: u32,
281    pub agents_using: Vec<String>,
282    pub success_count: u32,
283    pub failure_count: u32,
284    pub first_seen: Timestamp,
285    pub last_seen: Timestamp,
286    pub command_patterns: Vec<String>,
287    pub sessions: Vec<String>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub enum FileOpType {
292    Read,
293    Write,
294    Edit,
295    MultiEdit,
296    Delete,
297    Glob,
298    Grep,
299}
300
301impl FromStr for FileOpType {
302    type Err = anyhow::Error;
303
304    fn from_str(s: &str) -> Result<Self, Self::Err> {
305        match s {
306            "Read" => Ok(FileOpType::Read),
307            "Write" => Ok(FileOpType::Write),
308            "Edit" => Ok(FileOpType::Edit),
309            "MultiEdit" => Ok(FileOpType::MultiEdit),
310            "Delete" => Ok(FileOpType::Delete),
311            "Glob" => Ok(FileOpType::Glob),
312            "Grep" => Ok(FileOpType::Grep),
313            _ => Err(anyhow::anyhow!("Unknown file operation type: {s}")),
314        }
315    }
316}
317
318/// Analysis results for a session
319#[derive(Debug, Serialize, Deserialize)]
320pub struct SessionAnalysis {
321    pub session_id: String,
322    pub project_path: String,
323    pub start_time: Timestamp,
324    pub end_time: Timestamp,
325    pub duration_ms: u64,
326    pub agents: Vec<AgentInvocation>,
327    pub file_operations: Vec<FileOperation>,
328    pub file_to_agents: IndexMap<String, Vec<AgentAttribution>>,
329    pub agent_stats: IndexMap<String, AgentStatistics>,
330    pub collaboration_patterns: Vec<CollaborationPattern>,
331}
332
333/// Attribution of a file to an agent
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct AgentAttribution {
336    pub agent_type: String,
337    pub contribution_percent: f32,
338    pub confidence_score: f32,
339    pub operations: Vec<String>,
340    pub first_interaction: Timestamp,
341    pub last_interaction: Timestamp,
342}
343
344/// Statistics for an individual agent
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct AgentStatistics {
347    pub agent_type: String,
348    pub total_invocations: u32,
349    pub total_duration_ms: u64,
350    pub files_touched: u32,
351    pub tools_used: Vec<String>,
352    pub first_seen: Timestamp,
353    pub last_seen: Timestamp,
354}
355
356/// Collaboration patterns between agents
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct CollaborationPattern {
359    pub pattern_type: String,
360    pub agents: Vec<String>,
361    pub description: String,
362    pub frequency: u32,
363    pub confidence: f32,
364}
365
366/// Correlation between agents and tools
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct AgentToolCorrelation {
369    pub agent_type: String,
370    pub tool_name: String,
371    pub usage_count: u32,
372    pub success_rate: f32,
373    pub average_invocations_per_session: f32,
374}
375
376/// Complete tool usage analysis
377#[derive(Debug, Serialize, Deserialize)]
378pub struct ToolAnalysis {
379    pub session_id: String,
380    pub total_tool_invocations: u32,
381    pub tool_statistics: IndexMap<String, ToolStatistics>,
382    pub agent_tool_correlations: Vec<AgentToolCorrelation>,
383    pub tool_chains: Vec<ToolChain>,
384    pub category_breakdown: IndexMap<ToolCategory, u32>,
385}
386
387/// Sequence of tools used together
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct ToolChain {
390    pub tools: Vec<String>,
391    pub frequency: u32,
392    pub average_time_between_ms: u64,
393    pub typical_agent: Option<String>,
394    pub success_rate: f32,
395}
396
397/// Configuration for the analyzer
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct AnalyzerConfig {
400    pub session_dirs: Vec<String>,
401    pub agent_confidence_threshold: f32,
402    pub file_attribution_window_ms: u64,
403    pub exclude_patterns: Vec<String>,
404}
405
406impl Default for AnalyzerConfig {
407    fn default() -> Self {
408        Self {
409            session_dirs: vec![],
410            agent_confidence_threshold: 0.7,
411            file_attribution_window_ms: 300_000, // 5 minutes
412            exclude_patterns: vec![
413                "node_modules/".to_string(),
414                "target/".to_string(),
415                ".git/".to_string(),
416            ],
417        }
418    }
419}
420
421/// Parse an ISO 8601 timestamp string into a `jiff::Timestamp`
422///
423/// # Errors
424///
425/// Returns an error if the timestamp string is malformed or cannot be parsed
426pub fn parse_timestamp(timestamp_str: &str) -> Result<Timestamp, anyhow::Error> {
427    // Handle ISO 8601 timestamps from Claude session logs
428    Timestamp::from_str(timestamp_str)
429        .map_err(|e| anyhow::anyhow!("Failed to parse timestamp '{timestamp_str}': {e}"))
430}
431
432/// Helper to extract file path from various tool inputs
433#[must_use]
434pub fn extract_file_path(input: &serde_json::Value) -> Option<String> {
435    // Try different field names that might contain file paths
436    for field in &["file_path", "path", "pattern"] {
437        if let Some(path) = input.get(field).and_then(|v| v.as_str()) {
438            return Some(path.to_string());
439        }
440    }
441
442    // For MultiEdit, check the edits array
443    if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
444        if !edits.is_empty() {
445            if let Some(file_path) = input.get("file_path").and_then(|v| v.as_str()) {
446                return Some(file_path.to_string());
447            }
448        }
449    }
450
451    None
452}
453
454/// Agent type utilities
455/// Used in integration tests and public API
456#[allow(dead_code)]
457#[must_use]
458pub fn normalize_agent_name(agent_type: &str) -> String {
459    agent_type.to_lowercase().replace(['-', ' '], "_")
460}
461
462/// Used in integration tests and public API
463#[allow(dead_code)]
464#[must_use]
465pub fn get_agent_category(agent_type: &str) -> &'static str {
466    match agent_type {
467        "architect" | "backend-architect" | "frontend-developer" => "architecture",
468        "developer" | "rapid-prototyper" => "development",
469        "rust-performance-expert" | "rust-code-reviewer" => "rust-expert",
470        "debugger" | "test-writer-fixer" => "testing",
471        "technical-writer" => "documentation",
472        "devops-automator" | "overseer" => "operations",
473        "general-purpose" => "general",
474        _ => "other",
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn test_parse_timestamp() {
484        let timestamp_str = "2025-10-01T09:05:21.902Z";
485        let result = parse_timestamp(timestamp_str);
486        assert!(result.is_ok());
487    }
488
489    #[test]
490    fn test_newtype_wrappers() {
491        // Test SessionId
492        let session_id = SessionId::new("test-session".to_string());
493        assert_eq!(session_id.as_str(), "test-session");
494        assert_eq!(session_id.to_string(), "test-session");
495        assert_eq!(session_id.as_ref(), "test-session");
496
497        let session_id_from_str: SessionId = "another-session".into();
498        assert_eq!(session_id_from_str.as_str(), "another-session");
499
500        // Test AgentType
501        let agent_type = AgentType::new("architect".to_string());
502        assert_eq!(agent_type.as_str(), "architect");
503        assert_eq!(agent_type.to_string(), "architect");
504
505        // Test MessageId
506        let message_id = MessageId::new("msg-123".to_string());
507        assert_eq!(message_id.as_str(), "msg-123");
508        assert_eq!(message_id.to_string(), "msg-123");
509    }
510
511    #[test]
512    fn test_extract_file_path() {
513        let input = serde_json::json!({
514            "file_path": "/path/to/file.rs",
515            "description": "Edit file"
516        });
517
518        let path = extract_file_path(&input);
519        assert_eq!(path, Some("/path/to/file.rs".to_string()));
520    }
521
522    #[test]
523    fn test_normalize_agent_name() {
524        assert_eq!(
525            normalize_agent_name("rust-performance-expert"),
526            "rust_performance_expert"
527        );
528        assert_eq!(
529            normalize_agent_name("backend-architect"),
530            "backend_architect"
531        );
532    }
533
534    mod proptest_tests {
535        use super::*;
536        use proptest::prelude::*;
537
538        proptest! {
539            #[test]
540            fn test_normalize_agent_name_properties(
541                input in "[a-zA-Z0-9 -]{1,50}"
542            ) {
543                let result = normalize_agent_name(&input);
544
545                // Property 1: Result should only contain lowercase letters, numbers, and underscores
546                prop_assert!(result.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'));
547
548                // Property 2: Result should not be empty if input was not empty
549                if !input.trim().is_empty() {
550                    prop_assert!(!result.is_empty());
551                }
552            }
553
554            #[test]
555            fn test_parse_timestamp_properties(
556                year in 2020u16..2030,
557                month in 1u8..=12,
558                day in 1u8..=28, // Safe range to avoid month-specific issues
559                hour in 0u8..=23,
560                minute in 0u8..=59,
561                second in 0u8..=59,
562                millis in 0u16..1000
563            ) {
564                let timestamp_str = format!(
565                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
566                    year, month, day, hour, minute, second, millis
567                );
568
569                let result = parse_timestamp(&timestamp_str);
570
571                // Property: Valid ISO 8601 timestamps should always parse successfully
572                prop_assert!(result.is_ok(), "Failed to parse valid timestamp: {}", timestamp_str);
573
574                if let Ok(parsed) = result {
575                    // Property: Parsed timestamp should roundtrip correctly
576                    let reformatted = parsed.to_string();
577                    prop_assert!(reformatted.starts_with(&year.to_string()));
578                }
579            }
580
581            #[test]
582            fn test_extract_file_path_properties(
583                file_path in r"[a-zA-Z0-9_./\-]{1,100}"
584            ) {
585                let input = serde_json::json!({
586                    "file_path": file_path
587                });
588
589                let result = extract_file_path(&input);
590
591                // Property: If file_path field exists, it should be extracted
592                prop_assert_eq!(result, Some(file_path.clone()));
593
594                // Test with different field names
595                let input_path = serde_json::json!({
596                    "path": file_path
597                });
598                let result_path = extract_file_path(&input_path);
599                prop_assert_eq!(result_path, Some(file_path.clone()));
600            }
601
602            #[test]
603            fn test_newtype_wrapper_roundtrip(
604                session_id in "[a-zA-Z0-9-]{10,50}",
605                agent_type in "[a-zA-Z0-9-_]{3,30}",
606                message_id in "[a-zA-Z0-9-]{10,50}"
607            ) {
608                // Test SessionId roundtrip
609                let session = SessionId::new(session_id.clone());
610                prop_assert_eq!(session.as_str(), &session_id);
611                prop_assert_eq!(session.to_string(), session_id);
612
613                // Test AgentType roundtrip
614                let agent = AgentType::new(agent_type.clone());
615                prop_assert_eq!(agent.as_str(), &agent_type);
616                prop_assert_eq!(agent.to_string(), agent_type);
617
618                // Test MessageId roundtrip
619                let message = MessageId::new(message_id.clone());
620                prop_assert_eq!(message.as_str(), &message_id);
621                prop_assert_eq!(message.to_string(), message_id);
622            }
623        }
624    }
625}