Skip to main content

retro_core/
models.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Deserializer, Serialize};
3
4/// Deserialize a String that may be null — converts null to empty string.
5fn null_to_empty<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
6    Option::<String>::deserialize(d).map(|o| o.unwrap_or_default())
7}
8
9// ── Pattern types ──
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(rename_all = "snake_case")]
13pub enum PatternType {
14    RepetitiveInstruction,
15    RecurringMistake,
16    WorkflowPattern,
17    StaleContext,
18    RedundantContext,
19}
20
21impl std::fmt::Display for PatternType {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            Self::RepetitiveInstruction => write!(f, "repetitive_instruction"),
25            Self::RecurringMistake => write!(f, "recurring_mistake"),
26            Self::WorkflowPattern => write!(f, "workflow_pattern"),
27            Self::StaleContext => write!(f, "stale_context"),
28            Self::RedundantContext => write!(f, "redundant_context"),
29        }
30    }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "snake_case")]
35pub enum PatternStatus {
36    Discovered,
37    Active,
38    Archived,
39    Dismissed,
40}
41
42impl std::fmt::Display for PatternStatus {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Discovered => write!(f, "discovered"),
46            Self::Active => write!(f, "active"),
47            Self::Archived => write!(f, "archived"),
48            Self::Dismissed => write!(f, "dismissed"),
49        }
50    }
51}
52
53impl PatternStatus {
54    pub fn from_str(s: &str) -> Self {
55        match s {
56            "discovered" => Self::Discovered,
57            "active" => Self::Active,
58            "archived" => Self::Archived,
59            "dismissed" => Self::Dismissed,
60            _ => Self::Discovered,
61        }
62    }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66#[serde(rename_all = "snake_case")]
67pub enum SuggestedTarget {
68    Skill,
69    ClaudeMd,
70    GlobalAgent,
71    DbOnly,
72}
73
74impl std::fmt::Display for SuggestedTarget {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::Skill => write!(f, "skill"),
78            Self::ClaudeMd => write!(f, "claude_md"),
79            Self::GlobalAgent => write!(f, "global_agent"),
80            Self::DbOnly => write!(f, "db_only"),
81        }
82    }
83}
84
85impl SuggestedTarget {
86    pub fn from_str(s: &str) -> Self {
87        match s {
88            "skill" => Self::Skill,
89            "claude_md" => Self::ClaudeMd,
90            "global_agent" => Self::GlobalAgent,
91            "db_only" => Self::DbOnly,
92            _ => Self::DbOnly,
93        }
94    }
95}
96
97impl PatternType {
98    pub fn from_str(s: &str) -> Self {
99        match s {
100            "repetitive_instruction" => Self::RepetitiveInstruction,
101            "recurring_mistake" => Self::RecurringMistake,
102            "workflow_pattern" => Self::WorkflowPattern,
103            "stale_context" => Self::StaleContext,
104            "redundant_context" => Self::RedundantContext,
105            _ => Self::WorkflowPattern,
106        }
107    }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Pattern {
112    pub id: String,
113    pub pattern_type: PatternType,
114    pub description: String,
115    pub confidence: f64,
116    pub times_seen: i64,
117    pub first_seen: DateTime<Utc>,
118    pub last_seen: DateTime<Utc>,
119    pub last_projected: Option<DateTime<Utc>>,
120    pub status: PatternStatus,
121    pub source_sessions: Vec<String>,
122    pub related_files: Vec<String>,
123    pub suggested_content: String,
124    pub suggested_target: SuggestedTarget,
125    pub project: Option<String>,
126    pub generation_failed: bool,
127}
128
129// ── Session JSONL types ──
130
131/// Top-level entry in a session JSONL file.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(tag = "type")]
134pub enum SessionEntry {
135    #[serde(rename = "user")]
136    User(UserEntry),
137    #[serde(rename = "assistant")]
138    Assistant(AssistantEntry),
139    #[serde(rename = "summary")]
140    Summary(SummaryEntry),
141    #[serde(rename = "file-history-snapshot")]
142    FileHistorySnapshot(serde_json::Value),
143    #[serde(rename = "progress")]
144    Progress(serde_json::Value),
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct UserEntry {
150    pub uuid: String,
151    #[serde(default)]
152    pub parent_uuid: Option<String>,
153    #[serde(default)]
154    pub session_id: Option<String>,
155    #[serde(default)]
156    pub cwd: Option<String>,
157    #[serde(default)]
158    pub version: Option<String>,
159    #[serde(default)]
160    pub git_branch: Option<String>,
161    #[serde(default)]
162    pub timestamp: Option<String>,
163    pub message: UserMessage,
164    #[serde(default)]
165    pub is_sidechain: bool,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct UserMessage {
170    pub role: String,
171    pub content: MessageContent,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(untagged)]
176pub enum MessageContent {
177    Text(String),
178    Blocks(Vec<ContentBlock>),
179    /// Catch-all for newer content formats we don't handle yet.
180    Other(serde_json::Value),
181}
182
183impl MessageContent {
184    /// Extract the user-facing text from the message content.
185    pub fn as_text(&self) -> String {
186        match self {
187            MessageContent::Text(s) => s.clone(),
188            MessageContent::Blocks(blocks) => {
189                let mut parts = Vec::new();
190                for block in blocks {
191                    match block {
192                        ContentBlock::Text { text } => parts.push(text.clone()),
193                        ContentBlock::ToolResult { content, .. } => {
194                            if let Some(c) = content {
195                                parts.push(c.as_text());
196                            }
197                        }
198                        _ => {}
199                    }
200                }
201                parts.join("\n")
202            }
203            MessageContent::Other(_) => String::new(),
204        }
205    }
206
207    /// Returns true if this is a tool_result message (not a user prompt).
208    pub fn is_tool_result(&self) -> bool {
209        matches!(self, MessageContent::Blocks(blocks) if blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. })))
210    }
211
212    /// Returns true if this content is an unknown format we can't parse.
213    pub fn is_unknown(&self) -> bool {
214        matches!(self, MessageContent::Other(_))
215    }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(tag = "type")]
220pub enum ContentBlock {
221    #[serde(rename = "text")]
222    Text { text: String },
223    #[serde(rename = "thinking")]
224    Thinking {
225        thinking: String,
226        #[serde(default)]
227        signature: Option<String>,
228    },
229    #[serde(rename = "tool_use")]
230    ToolUse {
231        id: String,
232        name: String,
233        #[serde(default)]
234        input: serde_json::Value,
235    },
236    #[serde(rename = "tool_result")]
237    ToolResult {
238        tool_use_id: String,
239        #[serde(default)]
240        content: Option<ToolResultContent>,
241    },
242    /// Catch-all for new block types from future Claude versions.
243    #[serde(other)]
244    Unknown,
245}
246
247/// Tool result content can be a string or an array of content blocks.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249#[serde(untagged)]
250pub enum ToolResultContent {
251    Text(String),
252    Blocks(Vec<serde_json::Value>),
253}
254
255impl ToolResultContent {
256    pub fn as_text(&self) -> String {
257        match self {
258            Self::Text(s) => s.clone(),
259            Self::Blocks(blocks) => {
260                blocks
261                    .iter()
262                    .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
263                    .collect::<Vec<_>>()
264                    .join("\n")
265            }
266        }
267    }
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct AssistantEntry {
273    pub uuid: String,
274    #[serde(default)]
275    pub parent_uuid: Option<String>,
276    #[serde(default)]
277    pub session_id: Option<String>,
278    #[serde(default)]
279    pub cwd: Option<String>,
280    #[serde(default)]
281    pub version: Option<String>,
282    #[serde(default)]
283    pub git_branch: Option<String>,
284    #[serde(default)]
285    pub timestamp: Option<String>,
286    pub message: AssistantMessage,
287    #[serde(default)]
288    pub is_sidechain: bool,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct AssistantMessage {
293    pub role: String,
294    #[serde(default)]
295    pub model: Option<String>,
296    #[serde(default)]
297    pub content: Vec<ContentBlock>,
298    #[serde(default)]
299    pub stop_reason: Option<String>,
300    #[serde(default)]
301    pub usage: Option<Usage>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct Usage {
306    #[serde(default)]
307    pub input_tokens: u64,
308    #[serde(default)]
309    pub output_tokens: u64,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct SummaryEntry {
315    #[serde(default)]
316    pub uuid: String,
317    #[serde(default)]
318    pub parent_uuid: Option<String>,
319    #[serde(default)]
320    pub session_id: Option<String>,
321    #[serde(default)]
322    pub timestamp: Option<String>,
323    #[serde(default)]
324    pub summary: Option<String>,
325    /// Summaries may also carry a message field.
326    #[serde(default)]
327    pub message: Option<serde_json::Value>,
328}
329
330// ── Parsed session (for analysis) ──
331
332/// A parsed and processed session ready for analysis.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct Session {
335    pub session_id: String,
336    pub project: String,
337    pub session_path: String,
338    pub user_messages: Vec<ParsedUserMessage>,
339    pub assistant_messages: Vec<ParsedAssistantMessage>,
340    pub summaries: Vec<String>,
341    pub tools_used: Vec<String>,
342    pub errors: Vec<String>,
343    pub metadata: SessionMetadata,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct ParsedUserMessage {
348    pub text: String,
349    pub timestamp: Option<String>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct ParsedAssistantMessage {
354    pub text: String,
355    pub thinking_summary: Option<String>,
356    pub tools: Vec<String>,
357    pub timestamp: Option<String>,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct SessionMetadata {
362    pub cwd: Option<String>,
363    pub version: Option<String>,
364    pub git_branch: Option<String>,
365    pub model: Option<String>,
366}
367
368// ── History entry ──
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase")]
372pub struct HistoryEntry {
373    #[serde(default)]
374    pub display: Option<String>,
375    #[serde(default)]
376    pub timestamp: Option<u64>,
377    #[serde(default)]
378    pub project: Option<String>,
379    #[serde(default)]
380    pub session_id: Option<String>,
381}
382
383// ── Context snapshot ──
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct PluginSkillSummary {
387    pub plugin_name: String,
388    pub skill_name: String,
389    pub description: String,
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct ContextSnapshot {
394    pub claude_md: Option<String>,
395    pub skills: Vec<SkillFile>,
396    pub memory_md: Option<String>,
397    pub global_agents: Vec<AgentFile>,
398    #[serde(default)]
399    pub plugin_skills: Vec<PluginSkillSummary>,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct SkillFile {
404    pub path: String,
405    pub content: String,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct AgentFile {
410    pub path: String,
411    pub content: String,
412}
413
414// ── Ingestion tracking ──
415
416#[derive(Debug, Clone)]
417pub struct IngestedSession {
418    pub session_id: String,
419    pub project: String,
420    pub session_path: String,
421    pub file_size: u64,
422    pub file_mtime: String,
423    pub ingested_at: DateTime<Utc>,
424}
425
426// ── Analysis types ──
427
428/// AI response: either a new pattern or an update to an existing one.
429#[derive(Debug, Clone, Serialize, Deserialize)]
430#[serde(tag = "action")]
431pub enum PatternUpdate {
432    #[serde(rename = "new")]
433    New(NewPattern),
434    #[serde(rename = "update")]
435    Update(UpdateExisting),
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct NewPattern {
440    pub pattern_type: PatternType,
441    #[serde(deserialize_with = "null_to_empty")]
442    pub description: String,
443    pub confidence: f64,
444    #[serde(default)]
445    pub source_sessions: Vec<String>,
446    #[serde(default)]
447    pub related_files: Vec<String>,
448    #[serde(default, deserialize_with = "null_to_empty")]
449    pub suggested_content: String,
450    pub suggested_target: SuggestedTarget,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct UpdateExisting {
455    #[serde(deserialize_with = "null_to_empty")]
456    pub existing_id: String,
457    #[serde(default)]
458    pub new_sessions: Vec<String>,
459    pub new_confidence: f64,
460}
461
462/// Top-level AI response wrapper.
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct AnalysisResponse {
465    #[serde(default)]
466    pub reasoning: String,
467    pub patterns: Vec<PatternUpdate>,
468    #[serde(default)]
469    pub claude_md_edits: Vec<ClaudeMdEdit>,
470}
471
472// ── CLAUDE.md edit types ──
473
474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
475#[serde(rename_all = "snake_case")]
476pub enum ClaudeMdEditType {
477    Add,
478    Remove,
479    Reword,
480    Move,
481}
482
483impl std::fmt::Display for ClaudeMdEditType {
484    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485        match self {
486            Self::Add => write!(f, "add"),
487            Self::Remove => write!(f, "remove"),
488            Self::Reword => write!(f, "reword"),
489            Self::Move => write!(f, "move"),
490        }
491    }
492}
493
494/// A proposed edit to existing CLAUDE.md content (when full_management = true).
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct ClaudeMdEdit {
497    pub edit_type: ClaudeMdEditType,
498    #[serde(default)]
499    pub original_text: String,
500    #[serde(default)]
501    pub suggested_content: Option<String>,
502    #[serde(default)]
503    pub target_section: Option<String>,
504    #[serde(default)]
505    pub reasoning: String,
506}
507
508/// Claude CLI --output-format json wrapper.
509#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct ClaudeCliOutput {
511    #[serde(default)]
512    pub result: Option<String>,
513    #[serde(default)]
514    pub is_error: bool,
515    #[serde(default)]
516    pub duration_ms: u64,
517    #[serde(default)]
518    pub num_turns: u64,
519    #[serde(default)]
520    pub stop_reason: Option<String>,
521    #[serde(default)]
522    pub session_id: Option<String>,
523    #[serde(default)]
524    pub usage: Option<CliUsage>,
525    /// When `--json-schema` is used, the structured output appears here
526    /// as a parsed JSON value rather than in `result`.
527    #[serde(default)]
528    pub structured_output: Option<serde_json::Value>,
529}
530
531/// Token usage from Claude CLI output (nested inside `usage` field).
532#[derive(Debug, Clone, Serialize, Deserialize)]
533pub struct CliUsage {
534    #[serde(default)]
535    pub input_tokens: u64,
536    #[serde(default)]
537    pub output_tokens: u64,
538    #[serde(default)]
539    pub cache_creation_input_tokens: u64,
540    #[serde(default)]
541    pub cache_read_input_tokens: u64,
542}
543
544impl ClaudeCliOutput {
545    /// Total input tokens (direct + cache creation + cache read).
546    pub fn total_input_tokens(&self) -> u64 {
547        self.usage.as_ref().map_or(0, |u| {
548            u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
549        })
550    }
551
552    /// Total output tokens.
553    pub fn total_output_tokens(&self) -> u64 {
554        self.usage.as_ref().map_or(0, |u| u.output_tokens)
555    }
556}
557
558/// Audit log entry.
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct AuditEntry {
561    pub timestamp: DateTime<Utc>,
562    pub action: String,
563    pub details: serde_json::Value,
564}
565
566/// Per-batch analysis detail for diagnostics.
567#[derive(Debug, Clone)]
568pub struct BatchDetail {
569    pub batch_index: usize,
570    pub session_count: usize,
571    pub session_ids: Vec<String>,
572    pub prompt_chars: usize,
573    pub input_tokens: u64,
574    pub output_tokens: u64,
575    pub new_patterns: usize,
576    pub updated_patterns: usize,
577    pub reasoning: String,
578    pub ai_response_preview: String,
579}
580
581/// Result of an analysis run.
582#[derive(Debug, Clone)]
583pub struct AnalyzeResult {
584    pub sessions_analyzed: usize,
585    pub new_patterns: usize,
586    pub updated_patterns: usize,
587    pub total_patterns: usize,
588    pub input_tokens: u64,
589    pub output_tokens: u64,
590    pub batch_details: Vec<BatchDetail>,
591}
592
593/// Compact session format for serialization to AI prompts.
594#[derive(Debug, Clone, Serialize)]
595pub struct CompactSession {
596    pub session_id: String,
597    pub project: String,
598    pub user_messages: Vec<CompactUserMessage>,
599    pub tools_used: Vec<String>,
600    pub errors: Vec<String>,
601    pub thinking_highlights: Vec<String>,
602    pub summaries: Vec<String>,
603}
604
605#[derive(Debug, Clone, Serialize)]
606pub struct CompactUserMessage {
607    pub text: String,
608    #[serde(skip_serializing_if = "Option::is_none")]
609    pub timestamp: Option<String>,
610}
611
612/// Compact pattern format for inclusion in AI prompts.
613#[derive(Debug, Clone, Serialize)]
614pub struct CompactPattern {
615    pub id: String,
616    pub pattern_type: String,
617    pub description: String,
618    pub confidence: f64,
619    pub times_seen: i64,
620    pub suggested_target: String,
621}
622
623// ── Projection types ──
624
625/// A projection record — tracks what was generated and where it was applied.
626#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct Projection {
628    pub id: String,
629    pub pattern_id: String,
630    pub target_type: String,
631    pub target_path: String,
632    pub content: String,
633    pub applied_at: DateTime<Utc>,
634    pub pr_url: Option<String>,
635    pub status: ProjectionStatus,
636}
637
638/// A generated skill draft (output of AI skill generation).
639#[derive(Debug, Clone)]
640pub struct SkillDraft {
641    pub name: String,
642    pub content: String,
643    pub pattern_id: String,
644}
645
646/// Skill validation result from AI.
647#[derive(Debug, Clone, Serialize, Deserialize)]
648pub struct SkillValidation {
649    pub valid: bool,
650    #[serde(default)]
651    pub feedback: String,
652}
653
654/// A generated global agent draft.
655#[derive(Debug, Clone)]
656pub struct AgentDraft {
657    pub name: String,
658    pub content: String,
659    pub pattern_id: String,
660}
661
662/// A planned action for `retro apply`.
663#[derive(Debug, Clone)]
664pub struct ApplyAction {
665    pub pattern_id: String,
666    pub pattern_description: String,
667    pub target_type: SuggestedTarget,
668    pub target_path: String,
669    pub content: String,
670    pub track: ApplyTrack,
671}
672
673/// Status of a projection in the review queue.
674#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
675#[serde(rename_all = "snake_case")]
676pub enum ProjectionStatus {
677    PendingReview,
678    Applied,
679    Dismissed,
680}
681
682impl std::fmt::Display for ProjectionStatus {
683    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
684        match self {
685            Self::PendingReview => write!(f, "pending_review"),
686            Self::Applied => write!(f, "applied"),
687            Self::Dismissed => write!(f, "dismissed"),
688        }
689    }
690}
691
692impl ProjectionStatus {
693    pub fn from_str(s: &str) -> Option<Self> {
694        match s {
695            "pending_review" => Some(Self::PendingReview),
696            "applied" => Some(Self::Applied),
697            "dismissed" => Some(Self::Dismissed),
698            _ => None,
699        }
700    }
701}
702
703/// Whether a change is auto-applied (personal) or needs a PR (shared).
704#[derive(Debug, Clone, PartialEq)]
705pub enum ApplyTrack {
706    /// Auto-apply: global agents
707    Personal,
708    /// Needs PR (Phase 4): skills, CLAUDE.md rules
709    Shared,
710}
711
712impl std::fmt::Display for ApplyTrack {
713    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
714        match self {
715            Self::Personal => write!(f, "personal"),
716            Self::Shared => write!(f, "shared"),
717        }
718    }
719}
720
721/// The full apply plan — all actions to take.
722#[derive(Debug, Clone)]
723pub struct ApplyPlan {
724    pub actions: Vec<ApplyAction>,
725}
726
727impl ApplyPlan {
728    pub fn is_empty(&self) -> bool {
729        self.actions.is_empty()
730    }
731
732    pub fn personal_actions(&self) -> Vec<&ApplyAction> {
733        self.actions.iter().filter(|a| a.track == ApplyTrack::Personal).collect()
734    }
735
736    pub fn shared_actions(&self) -> Vec<&ApplyAction> {
737        self.actions.iter().filter(|a| a.track == ApplyTrack::Shared).collect()
738    }
739}
740
741// ── Knowledge graph types (v2) ──
742
743#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
744#[serde(rename_all = "snake_case")]
745pub enum NodeType {
746    Preference,
747    Pattern,
748    Rule,
749    Skill,
750    Memory,
751    Directive,
752}
753
754impl std::fmt::Display for NodeType {
755    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
756        match self {
757            Self::Preference => write!(f, "preference"),
758            Self::Pattern => write!(f, "pattern"),
759            Self::Rule => write!(f, "rule"),
760            Self::Skill => write!(f, "skill"),
761            Self::Memory => write!(f, "memory"),
762            Self::Directive => write!(f, "directive"),
763        }
764    }
765}
766
767impl NodeType {
768    pub fn from_str(s: &str) -> Self {
769        match s {
770            "preference" => Self::Preference,
771            "pattern" => Self::Pattern,
772            "rule" => Self::Rule,
773            "skill" => Self::Skill,
774            "memory" => Self::Memory,
775            "directive" => Self::Directive,
776            _ => Self::Pattern,
777        }
778    }
779}
780
781#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
782#[serde(rename_all = "snake_case")]
783pub enum NodeScope {
784    Global,
785    Project,
786}
787
788impl std::fmt::Display for NodeScope {
789    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
790        match self {
791            Self::Global => write!(f, "global"),
792            Self::Project => write!(f, "project"),
793        }
794    }
795}
796
797impl NodeScope {
798    pub fn from_str(s: &str) -> Self {
799        match s {
800            "global" => Self::Global,
801            _ => Self::Project,
802        }
803    }
804}
805
806#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
807#[serde(rename_all = "snake_case")]
808pub enum NodeStatus {
809    Active,
810    PendingReview,
811    Dismissed,
812    Archived,
813}
814
815impl std::fmt::Display for NodeStatus {
816    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
817        match self {
818            Self::Active => write!(f, "active"),
819            Self::PendingReview => write!(f, "pending_review"),
820            Self::Dismissed => write!(f, "dismissed"),
821            Self::Archived => write!(f, "archived"),
822        }
823    }
824}
825
826impl NodeStatus {
827    pub fn from_str(s: &str) -> Self {
828        match s {
829            "active" => Self::Active,
830            "pending_review" => Self::PendingReview,
831            "dismissed" => Self::Dismissed,
832            "archived" => Self::Archived,
833            _ => Self::Active,
834        }
835    }
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
839#[serde(rename_all = "snake_case")]
840pub enum EdgeType {
841    Supports,
842    Contradicts,
843    Supersedes,
844    DerivedFrom,
845    AppliesTo,
846}
847
848impl std::fmt::Display for EdgeType {
849    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
850        match self {
851            Self::Supports => write!(f, "supports"),
852            Self::Contradicts => write!(f, "contradicts"),
853            Self::Supersedes => write!(f, "supersedes"),
854            Self::DerivedFrom => write!(f, "derived_from"),
855            Self::AppliesTo => write!(f, "applies_to"),
856        }
857    }
858}
859
860impl EdgeType {
861    pub fn from_str(s: &str) -> Option<Self> {
862        match s {
863            "supports" => Some(Self::Supports),
864            "contradicts" => Some(Self::Contradicts),
865            "supersedes" => Some(Self::Supersedes),
866            "derived_from" => Some(Self::DerivedFrom),
867            "applies_to" => Some(Self::AppliesTo),
868            _ => None,
869        }
870    }
871}
872
873/// A node in the knowledge graph.
874#[derive(Debug, Clone, Serialize, Deserialize)]
875pub struct KnowledgeNode {
876    pub id: String,
877    pub node_type: NodeType,
878    pub scope: NodeScope,
879    pub project_id: Option<String>,
880    pub content: String,
881    pub confidence: f64,
882    pub status: NodeStatus,
883    pub created_at: DateTime<Utc>,
884    pub updated_at: DateTime<Utc>,
885    #[serde(default)]
886    pub projected_at: Option<String>,
887    #[serde(default)]
888    pub pr_url: Option<String>,
889}
890
891/// An edge in the knowledge graph.
892#[derive(Debug, Clone, Serialize, Deserialize)]
893pub struct KnowledgeEdge {
894    pub source_id: String,
895    pub target_id: String,
896    pub edge_type: EdgeType,
897    pub created_at: DateTime<Utc>,
898}
899
900/// A tracked project.
901#[derive(Debug, Clone, Serialize, Deserialize)]
902pub struct KnowledgeProject {
903    pub id: String,
904    pub path: String,
905    pub remote_url: Option<String>,
906    pub agent_type: String,
907    pub last_seen: DateTime<Utc>,
908}
909
910/// Operations the analyzer produces to mutate the knowledge graph.
911#[derive(Debug, Clone)]
912pub enum GraphOperation {
913    CreateNode {
914        node_type: NodeType,
915        scope: NodeScope,
916        project_id: Option<String>,
917        content: String,
918        confidence: f64,
919    },
920    CreateEdge {
921        source_id: String,
922        target_id: String,
923        edge_type: EdgeType,
924    },
925    UpdateNode {
926        id: String,
927        confidence: Option<f64>,
928        content: Option<String>,
929    },
930    MergeNodes {
931        keep_id: String,
932        remove_id: String,
933    },
934}
935
936/// AI response for v2 graph-based analysis.
937#[derive(Debug, Clone, Serialize, Deserialize)]
938pub struct GraphAnalysisResponse {
939    #[serde(default)]
940    pub reasoning: String,
941    pub operations: Vec<GraphOperationResponse>,
942}
943
944/// A single operation from the AI response (before conversion to GraphOperation).
945#[derive(Debug, Clone, Serialize, Deserialize)]
946pub struct GraphOperationResponse {
947    pub action: String,
948    #[serde(default)]
949    pub node_type: Option<String>,
950    #[serde(default)]
951    pub scope: Option<String>,
952    #[serde(default)]
953    pub project_id: Option<String>,
954    #[serde(default)]
955    pub content: Option<String>,
956    #[serde(default)]
957    pub confidence: Option<f64>,
958    #[serde(default)]
959    pub node_id: Option<String>,
960    #[serde(default)]
961    pub new_confidence: Option<f64>,
962    #[serde(default)]
963    pub new_content: Option<String>,
964    #[serde(default)]
965    pub source_id: Option<String>,
966    #[serde(default)]
967    pub target_id: Option<String>,
968    #[serde(default)]
969    pub edge_type: Option<String>,
970    #[serde(default)]
971    pub keep_id: Option<String>,
972    #[serde(default)]
973    pub remove_id: Option<String>,
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979
980    #[test]
981    fn test_projection_status_display() {
982        assert_eq!(ProjectionStatus::PendingReview.to_string(), "pending_review");
983        assert_eq!(ProjectionStatus::Applied.to_string(), "applied");
984        assert_eq!(ProjectionStatus::Dismissed.to_string(), "dismissed");
985    }
986
987    #[test]
988    fn test_projection_status_from_str() {
989        assert_eq!(ProjectionStatus::from_str("pending_review"), Some(ProjectionStatus::PendingReview));
990        assert_eq!(ProjectionStatus::from_str("applied"), Some(ProjectionStatus::Applied));
991        assert_eq!(ProjectionStatus::from_str("dismissed"), Some(ProjectionStatus::Dismissed));
992        assert_eq!(ProjectionStatus::from_str("unknown"), None);
993    }
994
995    #[test]
996    fn test_claude_md_edit_type_serde() {
997        let edit = ClaudeMdEdit {
998            edit_type: ClaudeMdEditType::Reword,
999            original_text: "No async".to_string(),
1000            suggested_content: Some("Sync only — no tokio, no async".to_string()),
1001            target_section: None,
1002            reasoning: "Too terse".to_string(),
1003        };
1004        let json = serde_json::to_string(&edit).unwrap();
1005        let parsed: ClaudeMdEdit = serde_json::from_str(&json).unwrap();
1006        assert_eq!(parsed.edit_type, ClaudeMdEditType::Reword);
1007        assert_eq!(parsed.original_text, "No async");
1008        assert_eq!(parsed.suggested_content.unwrap(), "Sync only — no tokio, no async");
1009    }
1010
1011    #[test]
1012    fn test_claude_md_edit_type_display() {
1013        assert_eq!(ClaudeMdEditType::Add.to_string(), "add");
1014        assert_eq!(ClaudeMdEditType::Remove.to_string(), "remove");
1015        assert_eq!(ClaudeMdEditType::Reword.to_string(), "reword");
1016        assert_eq!(ClaudeMdEditType::Move.to_string(), "move");
1017    }
1018
1019    #[test]
1020    fn test_analysis_response_with_edits() {
1021        let json = r#"{
1022            "reasoning": "test",
1023            "patterns": [],
1024            "claude_md_edits": [
1025                {
1026                    "edit_type": "remove",
1027                    "original_text": "stale rule",
1028                    "reasoning": "no longer relevant"
1029                }
1030            ]
1031        }"#;
1032        let resp: AnalysisResponse = serde_json::from_str(json).unwrap();
1033        assert_eq!(resp.claude_md_edits.len(), 1);
1034        assert_eq!(resp.claude_md_edits[0].edit_type, ClaudeMdEditType::Remove);
1035    }
1036
1037    #[test]
1038    fn test_analysis_response_without_edits() {
1039        let json = r#"{"reasoning": "test", "patterns": []}"#;
1040        let resp: AnalysisResponse = serde_json::from_str(json).unwrap();
1041        assert!(resp.claude_md_edits.is_empty());
1042    }
1043
1044    #[test]
1045    fn test_node_type_display_and_from_str() {
1046        assert_eq!(NodeType::Preference.to_string(), "preference");
1047        assert_eq!(NodeType::Pattern.to_string(), "pattern");
1048        assert_eq!(NodeType::Rule.to_string(), "rule");
1049        assert_eq!(NodeType::Skill.to_string(), "skill");
1050        assert_eq!(NodeType::Memory.to_string(), "memory");
1051        assert_eq!(NodeType::Directive.to_string(), "directive");
1052        assert_eq!(NodeType::from_str("rule"), NodeType::Rule);
1053        assert_eq!(NodeType::from_str("unknown"), NodeType::Pattern); // default
1054    }
1055
1056    #[test]
1057    fn test_node_scope_display_and_from_str() {
1058        assert_eq!(NodeScope::Global.to_string(), "global");
1059        assert_eq!(NodeScope::Project.to_string(), "project");
1060        assert_eq!(NodeScope::from_str("global"), NodeScope::Global);
1061        assert_eq!(NodeScope::from_str("unknown"), NodeScope::Project); // default
1062    }
1063
1064    #[test]
1065    fn test_node_status_display_and_from_str() {
1066        assert_eq!(NodeStatus::Active.to_string(), "active");
1067        assert_eq!(NodeStatus::PendingReview.to_string(), "pending_review");
1068        assert_eq!(NodeStatus::Dismissed.to_string(), "dismissed");
1069        assert_eq!(NodeStatus::Archived.to_string(), "archived");
1070        assert_eq!(NodeStatus::from_str("pending_review"), NodeStatus::PendingReview);
1071        assert_eq!(NodeStatus::from_str("unknown"), NodeStatus::Active); // default
1072    }
1073
1074    #[test]
1075    fn test_edge_type_display_and_from_str() {
1076        assert_eq!(EdgeType::Supports.to_string(), "supports");
1077        assert_eq!(EdgeType::Contradicts.to_string(), "contradicts");
1078        assert_eq!(EdgeType::Supersedes.to_string(), "supersedes");
1079        assert_eq!(EdgeType::DerivedFrom.to_string(), "derived_from");
1080        assert_eq!(EdgeType::AppliesTo.to_string(), "applies_to");
1081        assert_eq!(EdgeType::from_str("contradicts"), Some(EdgeType::Contradicts));
1082        assert_eq!(EdgeType::from_str("unknown"), None);
1083    }
1084
1085    #[test]
1086    fn test_knowledge_node_struct() {
1087        let node = KnowledgeNode {
1088            id: "test-id".to_string(),
1089            node_type: NodeType::Rule,
1090            scope: NodeScope::Project,
1091            project_id: Some("my-app".to_string()),
1092            content: "Always run tests".to_string(),
1093            confidence: 0.85,
1094            status: NodeStatus::Active,
1095            created_at: Utc::now(),
1096            updated_at: Utc::now(),
1097            projected_at: None,
1098            pr_url: None,
1099        };
1100        assert_eq!(node.node_type, NodeType::Rule);
1101        assert_eq!(node.scope, NodeScope::Project);
1102        assert!(node.project_id.is_some());
1103    }
1104
1105    #[test]
1106    fn test_knowledge_edge_struct() {
1107        let edge = KnowledgeEdge {
1108            source_id: "node-1".to_string(),
1109            target_id: "node-2".to_string(),
1110            edge_type: EdgeType::Supports,
1111            created_at: Utc::now(),
1112        };
1113        assert_eq!(edge.edge_type, EdgeType::Supports);
1114    }
1115
1116    #[test]
1117    fn test_graph_analysis_response_deserialize() {
1118        let json = r#"{
1119            "reasoning": "Found a recurring pattern",
1120            "operations": [
1121                {
1122                    "action": "create_node",
1123                    "node_type": "rule",
1124                    "scope": "project",
1125                    "content": "Always run tests",
1126                    "confidence": 0.85
1127                },
1128                {
1129                    "action": "update_node",
1130                    "node_id": "existing-1",
1131                    "new_confidence": 0.9
1132                }
1133            ]
1134        }"#;
1135        let resp: GraphAnalysisResponse = serde_json::from_str(json).unwrap();
1136        assert_eq!(resp.operations.len(), 2);
1137        assert_eq!(resp.reasoning, "Found a recurring pattern");
1138    }
1139}