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/// Result of a v2 graph-based analysis run.
594#[derive(Debug, Clone)]
595pub struct AnalyzeV2Result {
596    pub sessions_analyzed: usize,
597    pub nodes_created: usize,
598    pub nodes_updated: usize,
599    pub edges_created: usize,
600    pub nodes_merged: usize,
601    pub input_tokens: u64,
602    pub output_tokens: u64,
603    pub batch_count: usize,
604}
605
606/// Compact session format for serialization to AI prompts.
607#[derive(Debug, Clone, Serialize)]
608pub struct CompactSession {
609    pub session_id: String,
610    pub project: String,
611    pub user_messages: Vec<CompactUserMessage>,
612    pub tools_used: Vec<String>,
613    pub errors: Vec<String>,
614    pub thinking_highlights: Vec<String>,
615    pub summaries: Vec<String>,
616}
617
618#[derive(Debug, Clone, Serialize)]
619pub struct CompactUserMessage {
620    pub text: String,
621    #[serde(skip_serializing_if = "Option::is_none")]
622    pub timestamp: Option<String>,
623}
624
625/// Compact pattern format for inclusion in AI prompts.
626#[derive(Debug, Clone, Serialize)]
627pub struct CompactPattern {
628    pub id: String,
629    pub pattern_type: String,
630    pub description: String,
631    pub confidence: f64,
632    pub times_seen: i64,
633    pub suggested_target: String,
634}
635
636// ── Projection types ──
637
638/// A projection record — tracks what was generated and where it was applied.
639#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct Projection {
641    pub id: String,
642    pub pattern_id: String,
643    pub target_type: String,
644    pub target_path: String,
645    pub content: String,
646    pub applied_at: DateTime<Utc>,
647    pub pr_url: Option<String>,
648    pub status: ProjectionStatus,
649}
650
651/// A generated skill draft (output of AI skill generation).
652#[derive(Debug, Clone)]
653pub struct SkillDraft {
654    pub name: String,
655    pub content: String,
656    pub pattern_id: String,
657}
658
659/// Skill validation result from AI.
660#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct SkillValidation {
662    pub valid: bool,
663    #[serde(default)]
664    pub feedback: String,
665}
666
667/// A generated global agent draft.
668#[derive(Debug, Clone)]
669pub struct AgentDraft {
670    pub name: String,
671    pub content: String,
672    pub pattern_id: String,
673}
674
675/// A planned action for `retro apply`.
676#[derive(Debug, Clone)]
677pub struct ApplyAction {
678    pub pattern_id: String,
679    pub pattern_description: String,
680    pub target_type: SuggestedTarget,
681    pub target_path: String,
682    pub content: String,
683    pub track: ApplyTrack,
684}
685
686/// Status of a projection in the review queue.
687#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
688#[serde(rename_all = "snake_case")]
689pub enum ProjectionStatus {
690    PendingReview,
691    Applied,
692    Dismissed,
693}
694
695impl std::fmt::Display for ProjectionStatus {
696    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697        match self {
698            Self::PendingReview => write!(f, "pending_review"),
699            Self::Applied => write!(f, "applied"),
700            Self::Dismissed => write!(f, "dismissed"),
701        }
702    }
703}
704
705impl ProjectionStatus {
706    pub fn from_str(s: &str) -> Option<Self> {
707        match s {
708            "pending_review" => Some(Self::PendingReview),
709            "applied" => Some(Self::Applied),
710            "dismissed" => Some(Self::Dismissed),
711            _ => None,
712        }
713    }
714}
715
716/// Whether a change is auto-applied (personal) or needs a PR (shared).
717#[derive(Debug, Clone, PartialEq)]
718pub enum ApplyTrack {
719    /// Auto-apply: global agents
720    Personal,
721    /// Needs PR (Phase 4): skills, CLAUDE.md rules
722    Shared,
723}
724
725impl std::fmt::Display for ApplyTrack {
726    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
727        match self {
728            Self::Personal => write!(f, "personal"),
729            Self::Shared => write!(f, "shared"),
730        }
731    }
732}
733
734/// The full apply plan — all actions to take.
735#[derive(Debug, Clone)]
736pub struct ApplyPlan {
737    pub actions: Vec<ApplyAction>,
738}
739
740impl ApplyPlan {
741    pub fn is_empty(&self) -> bool {
742        self.actions.is_empty()
743    }
744
745    pub fn personal_actions(&self) -> Vec<&ApplyAction> {
746        self.actions.iter().filter(|a| a.track == ApplyTrack::Personal).collect()
747    }
748
749    pub fn shared_actions(&self) -> Vec<&ApplyAction> {
750        self.actions.iter().filter(|a| a.track == ApplyTrack::Shared).collect()
751    }
752}
753
754// ── Knowledge graph types (v2) ──
755
756#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
757#[serde(rename_all = "snake_case")]
758pub enum NodeType {
759    Preference,
760    Pattern,
761    Rule,
762    Skill,
763    Memory,
764    Directive,
765}
766
767impl std::fmt::Display for NodeType {
768    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
769        match self {
770            Self::Preference => write!(f, "preference"),
771            Self::Pattern => write!(f, "pattern"),
772            Self::Rule => write!(f, "rule"),
773            Self::Skill => write!(f, "skill"),
774            Self::Memory => write!(f, "memory"),
775            Self::Directive => write!(f, "directive"),
776        }
777    }
778}
779
780impl NodeType {
781    pub fn from_str(s: &str) -> Self {
782        match s {
783            "preference" => Self::Preference,
784            "pattern" => Self::Pattern,
785            "rule" => Self::Rule,
786            "skill" => Self::Skill,
787            "memory" => Self::Memory,
788            "directive" => Self::Directive,
789            _ => Self::Pattern,
790        }
791    }
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
795#[serde(rename_all = "snake_case")]
796pub enum NodeScope {
797    Global,
798    Project,
799}
800
801impl std::fmt::Display for NodeScope {
802    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
803        match self {
804            Self::Global => write!(f, "global"),
805            Self::Project => write!(f, "project"),
806        }
807    }
808}
809
810impl NodeScope {
811    pub fn from_str(s: &str) -> Self {
812        match s {
813            "global" => Self::Global,
814            _ => Self::Project,
815        }
816    }
817}
818
819#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
820#[serde(rename_all = "snake_case")]
821pub enum NodeStatus {
822    Active,
823    PendingReview,
824    Dismissed,
825    Archived,
826}
827
828impl std::fmt::Display for NodeStatus {
829    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
830        match self {
831            Self::Active => write!(f, "active"),
832            Self::PendingReview => write!(f, "pending_review"),
833            Self::Dismissed => write!(f, "dismissed"),
834            Self::Archived => write!(f, "archived"),
835        }
836    }
837}
838
839impl NodeStatus {
840    pub fn from_str(s: &str) -> Self {
841        match s {
842            "active" => Self::Active,
843            "pending_review" => Self::PendingReview,
844            "dismissed" => Self::Dismissed,
845            "archived" => Self::Archived,
846            _ => Self::Active,
847        }
848    }
849}
850
851#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
852#[serde(rename_all = "snake_case")]
853pub enum EdgeType {
854    Supports,
855    Contradicts,
856    Supersedes,
857    DerivedFrom,
858    AppliesTo,
859}
860
861impl std::fmt::Display for EdgeType {
862    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
863        match self {
864            Self::Supports => write!(f, "supports"),
865            Self::Contradicts => write!(f, "contradicts"),
866            Self::Supersedes => write!(f, "supersedes"),
867            Self::DerivedFrom => write!(f, "derived_from"),
868            Self::AppliesTo => write!(f, "applies_to"),
869        }
870    }
871}
872
873impl EdgeType {
874    pub fn from_str(s: &str) -> Option<Self> {
875        match s {
876            "supports" => Some(Self::Supports),
877            "contradicts" => Some(Self::Contradicts),
878            "supersedes" => Some(Self::Supersedes),
879            "derived_from" => Some(Self::DerivedFrom),
880            "applies_to" => Some(Self::AppliesTo),
881            _ => None,
882        }
883    }
884}
885
886/// A node in the knowledge graph.
887#[derive(Debug, Clone, Serialize, Deserialize)]
888pub struct KnowledgeNode {
889    pub id: String,
890    pub node_type: NodeType,
891    pub scope: NodeScope,
892    pub project_id: Option<String>,
893    pub content: String,
894    pub confidence: f64,
895    pub status: NodeStatus,
896    pub created_at: DateTime<Utc>,
897    pub updated_at: DateTime<Utc>,
898    #[serde(default)]
899    pub projected_at: Option<String>,
900    #[serde(default)]
901    pub pr_url: Option<String>,
902}
903
904/// An edge in the knowledge graph.
905#[derive(Debug, Clone, Serialize, Deserialize)]
906pub struct KnowledgeEdge {
907    pub source_id: String,
908    pub target_id: String,
909    pub edge_type: EdgeType,
910    pub created_at: DateTime<Utc>,
911}
912
913/// A tracked project.
914#[derive(Debug, Clone, Serialize, Deserialize)]
915pub struct KnowledgeProject {
916    pub id: String,
917    pub path: String,
918    pub remote_url: Option<String>,
919    pub agent_type: String,
920    pub last_seen: DateTime<Utc>,
921}
922
923/// Operations the analyzer produces to mutate the knowledge graph.
924#[derive(Debug, Clone)]
925pub enum GraphOperation {
926    CreateNode {
927        node_type: NodeType,
928        scope: NodeScope,
929        project_id: Option<String>,
930        content: String,
931        confidence: f64,
932    },
933    CreateEdge {
934        source_id: String,
935        target_id: String,
936        edge_type: EdgeType,
937    },
938    UpdateNode {
939        id: String,
940        confidence: Option<f64>,
941        content: Option<String>,
942    },
943    MergeNodes {
944        keep_id: String,
945        remove_id: String,
946    },
947}
948
949/// AI response for v2 graph-based analysis.
950#[derive(Debug, Clone, Serialize, Deserialize)]
951pub struct GraphAnalysisResponse {
952    #[serde(default)]
953    pub reasoning: String,
954    pub operations: Vec<GraphOperationResponse>,
955}
956
957/// A single operation from the AI response (before conversion to GraphOperation).
958#[derive(Debug, Clone, Serialize, Deserialize)]
959pub struct GraphOperationResponse {
960    pub action: String,
961    #[serde(default)]
962    pub node_type: Option<String>,
963    #[serde(default)]
964    pub scope: Option<String>,
965    #[serde(default)]
966    pub project_id: Option<String>,
967    #[serde(default)]
968    pub content: Option<String>,
969    #[serde(default)]
970    pub confidence: Option<f64>,
971    #[serde(default)]
972    pub node_id: Option<String>,
973    #[serde(default)]
974    pub new_confidence: Option<f64>,
975    #[serde(default)]
976    pub new_content: Option<String>,
977    #[serde(default)]
978    pub source_id: Option<String>,
979    #[serde(default)]
980    pub target_id: Option<String>,
981    #[serde(default)]
982    pub edge_type: Option<String>,
983    #[serde(default)]
984    pub keep_id: Option<String>,
985    #[serde(default)]
986    pub remove_id: Option<String>,
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992
993    #[test]
994    fn test_projection_status_display() {
995        assert_eq!(ProjectionStatus::PendingReview.to_string(), "pending_review");
996        assert_eq!(ProjectionStatus::Applied.to_string(), "applied");
997        assert_eq!(ProjectionStatus::Dismissed.to_string(), "dismissed");
998    }
999
1000    #[test]
1001    fn test_projection_status_from_str() {
1002        assert_eq!(ProjectionStatus::from_str("pending_review"), Some(ProjectionStatus::PendingReview));
1003        assert_eq!(ProjectionStatus::from_str("applied"), Some(ProjectionStatus::Applied));
1004        assert_eq!(ProjectionStatus::from_str("dismissed"), Some(ProjectionStatus::Dismissed));
1005        assert_eq!(ProjectionStatus::from_str("unknown"), None);
1006    }
1007
1008    #[test]
1009    fn test_claude_md_edit_type_serde() {
1010        let edit = ClaudeMdEdit {
1011            edit_type: ClaudeMdEditType::Reword,
1012            original_text: "No async".to_string(),
1013            suggested_content: Some("Sync only — no tokio, no async".to_string()),
1014            target_section: None,
1015            reasoning: "Too terse".to_string(),
1016        };
1017        let json = serde_json::to_string(&edit).unwrap();
1018        let parsed: ClaudeMdEdit = serde_json::from_str(&json).unwrap();
1019        assert_eq!(parsed.edit_type, ClaudeMdEditType::Reword);
1020        assert_eq!(parsed.original_text, "No async");
1021        assert_eq!(parsed.suggested_content.unwrap(), "Sync only — no tokio, no async");
1022    }
1023
1024    #[test]
1025    fn test_claude_md_edit_type_display() {
1026        assert_eq!(ClaudeMdEditType::Add.to_string(), "add");
1027        assert_eq!(ClaudeMdEditType::Remove.to_string(), "remove");
1028        assert_eq!(ClaudeMdEditType::Reword.to_string(), "reword");
1029        assert_eq!(ClaudeMdEditType::Move.to_string(), "move");
1030    }
1031
1032    #[test]
1033    fn test_analysis_response_with_edits() {
1034        let json = r#"{
1035            "reasoning": "test",
1036            "patterns": [],
1037            "claude_md_edits": [
1038                {
1039                    "edit_type": "remove",
1040                    "original_text": "stale rule",
1041                    "reasoning": "no longer relevant"
1042                }
1043            ]
1044        }"#;
1045        let resp: AnalysisResponse = serde_json::from_str(json).unwrap();
1046        assert_eq!(resp.claude_md_edits.len(), 1);
1047        assert_eq!(resp.claude_md_edits[0].edit_type, ClaudeMdEditType::Remove);
1048    }
1049
1050    #[test]
1051    fn test_analysis_response_without_edits() {
1052        let json = r#"{"reasoning": "test", "patterns": []}"#;
1053        let resp: AnalysisResponse = serde_json::from_str(json).unwrap();
1054        assert!(resp.claude_md_edits.is_empty());
1055    }
1056
1057    #[test]
1058    fn test_node_type_display_and_from_str() {
1059        assert_eq!(NodeType::Preference.to_string(), "preference");
1060        assert_eq!(NodeType::Pattern.to_string(), "pattern");
1061        assert_eq!(NodeType::Rule.to_string(), "rule");
1062        assert_eq!(NodeType::Skill.to_string(), "skill");
1063        assert_eq!(NodeType::Memory.to_string(), "memory");
1064        assert_eq!(NodeType::Directive.to_string(), "directive");
1065        assert_eq!(NodeType::from_str("rule"), NodeType::Rule);
1066        assert_eq!(NodeType::from_str("unknown"), NodeType::Pattern); // default
1067    }
1068
1069    #[test]
1070    fn test_node_scope_display_and_from_str() {
1071        assert_eq!(NodeScope::Global.to_string(), "global");
1072        assert_eq!(NodeScope::Project.to_string(), "project");
1073        assert_eq!(NodeScope::from_str("global"), NodeScope::Global);
1074        assert_eq!(NodeScope::from_str("unknown"), NodeScope::Project); // default
1075    }
1076
1077    #[test]
1078    fn test_node_status_display_and_from_str() {
1079        assert_eq!(NodeStatus::Active.to_string(), "active");
1080        assert_eq!(NodeStatus::PendingReview.to_string(), "pending_review");
1081        assert_eq!(NodeStatus::Dismissed.to_string(), "dismissed");
1082        assert_eq!(NodeStatus::Archived.to_string(), "archived");
1083        assert_eq!(NodeStatus::from_str("pending_review"), NodeStatus::PendingReview);
1084        assert_eq!(NodeStatus::from_str("unknown"), NodeStatus::Active); // default
1085    }
1086
1087    #[test]
1088    fn test_edge_type_display_and_from_str() {
1089        assert_eq!(EdgeType::Supports.to_string(), "supports");
1090        assert_eq!(EdgeType::Contradicts.to_string(), "contradicts");
1091        assert_eq!(EdgeType::Supersedes.to_string(), "supersedes");
1092        assert_eq!(EdgeType::DerivedFrom.to_string(), "derived_from");
1093        assert_eq!(EdgeType::AppliesTo.to_string(), "applies_to");
1094        assert_eq!(EdgeType::from_str("contradicts"), Some(EdgeType::Contradicts));
1095        assert_eq!(EdgeType::from_str("unknown"), None);
1096    }
1097
1098    #[test]
1099    fn test_knowledge_node_struct() {
1100        let node = KnowledgeNode {
1101            id: "test-id".to_string(),
1102            node_type: NodeType::Rule,
1103            scope: NodeScope::Project,
1104            project_id: Some("my-app".to_string()),
1105            content: "Always run tests".to_string(),
1106            confidence: 0.85,
1107            status: NodeStatus::Active,
1108            created_at: Utc::now(),
1109            updated_at: Utc::now(),
1110            projected_at: None,
1111            pr_url: None,
1112        };
1113        assert_eq!(node.node_type, NodeType::Rule);
1114        assert_eq!(node.scope, NodeScope::Project);
1115        assert!(node.project_id.is_some());
1116    }
1117
1118    #[test]
1119    fn test_knowledge_edge_struct() {
1120        let edge = KnowledgeEdge {
1121            source_id: "node-1".to_string(),
1122            target_id: "node-2".to_string(),
1123            edge_type: EdgeType::Supports,
1124            created_at: Utc::now(),
1125        };
1126        assert_eq!(edge.edge_type, EdgeType::Supports);
1127    }
1128
1129    #[test]
1130    fn test_graph_analysis_response_deserialize() {
1131        let json = r#"{
1132            "reasoning": "Found a recurring pattern",
1133            "operations": [
1134                {
1135                    "action": "create_node",
1136                    "node_type": "rule",
1137                    "scope": "project",
1138                    "content": "Always run tests",
1139                    "confidence": 0.85
1140                },
1141                {
1142                    "action": "update_node",
1143                    "node_id": "existing-1",
1144                    "new_confidence": 0.9
1145                }
1146            ]
1147        }"#;
1148        let resp: GraphAnalysisResponse = serde_json::from_str(json).unwrap();
1149        assert_eq!(resp.operations.len(), 2);
1150        assert_eq!(resp.reasoning, "Found a recurring pattern");
1151    }
1152}