Skip to main content

retro_core/analysis/
mod.rs

1pub mod backend;
2pub mod claude_cli;
3pub mod merge;
4pub mod prompts;
5
6use crate::config::Config;
7use crate::db;
8use crate::errors::CoreError;
9use crate::ingest::{context, session};
10use crate::models::{
11    AnalysisResponse, AnalyzeResult, AnalyzeV2Result, BatchDetail, EdgeType,
12    GraphAnalysisResponse, GraphOperation, NodeScope, NodeStatus, NodeType, Pattern,
13    PatternStatus, PatternType, SuggestedTarget,
14};
15use crate::scrub;
16use chrono::{Duration, Utc};
17use rusqlite::Connection;
18use std::path::Path;
19
20use backend::AnalysisBackend;
21use claude_cli::ClaudeCliBackend;
22
23pub const BATCH_SIZE: usize = 20;
24
25/// JSON schema for constrained decoding of analysis responses.
26/// Flat schema — serde's `#[serde(tag = "action")]` handles variant discrimination.
27/// All fields optional except `action`; `additionalProperties: false` required by structured output.
28pub const ANALYSIS_RESPONSE_SCHEMA: &str = r#"{
29  "type": "object",
30  "properties": {
31    "reasoning": {"type": "string"},
32    "patterns": {
33      "type": "array",
34      "items": {
35        "type": "object",
36        "properties": {
37          "action": {"type": "string", "enum": ["new", "update"]},
38          "pattern_type": {"type": "string", "enum": ["repetitive_instruction", "recurring_mistake", "workflow_pattern", "stale_context", "redundant_context"]},
39          "description": {"type": "string"},
40          "confidence": {"type": "number"},
41          "source_sessions": {"type": "array", "items": {"type": "string"}},
42          "related_files": {"type": "array", "items": {"type": "string"}},
43          "suggested_content": {"type": "string"},
44          "suggested_target": {"type": "string", "enum": ["skill", "claude_md", "global_agent", "db_only"]},
45          "existing_id": {"type": "string"},
46          "new_sessions": {"type": "array", "items": {"type": "string"}},
47          "new_confidence": {"type": "number"}
48        },
49        "required": ["action"],
50        "additionalProperties": false
51      }
52    }
53  },
54  "required": ["reasoning", "patterns"],
55  "additionalProperties": false
56}"#;
57
58/// JSON schema for v2 graph-based analysis responses.
59pub const GRAPH_ANALYSIS_RESPONSE_SCHEMA: &str = r#"{
60    "type": "object",
61    "properties": {
62        "reasoning": { "type": "string", "description": "1-2 sentence summary of what you observed" },
63        "operations": {
64            "type": "array",
65            "items": {
66                "type": "object",
67                "properties": {
68                    "action": { "type": "string", "enum": ["create_node", "update_node", "create_edge", "merge_nodes"] },
69                    "node_type": { "type": "string", "enum": ["preference", "pattern", "rule", "skill", "memory", "directive"] },
70                    "scope": { "type": "string", "enum": ["global", "project"] },
71                    "project_id": { "type": "string" },
72                    "content": { "type": "string" },
73                    "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
74                    "node_id": { "type": "string" },
75                    "new_confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
76                    "new_content": { "type": "string" },
77                    "source_id": { "type": "string" },
78                    "target_id": { "type": "string" },
79                    "edge_type": { "type": "string", "enum": ["supports", "contradicts", "supersedes", "derived_from", "applies_to"] },
80                    "keep_id": { "type": "string" },
81                    "remove_id": { "type": "string" }
82                },
83                "required": ["action"],
84                "additionalProperties": false
85            }
86        }
87    },
88    "required": ["reasoning", "operations"],
89    "additionalProperties": false
90}"#;
91
92/// Extended JSON schema that includes `claude_md_edits` for full_management mode.
93/// Built programmatically from `ANALYSIS_RESPONSE_SCHEMA` to avoid duplication.
94pub fn full_management_analysis_schema() -> String {
95    let mut schema: serde_json::Value = serde_json::from_str(ANALYSIS_RESPONSE_SCHEMA)
96        .expect("ANALYSIS_RESPONSE_SCHEMA must be valid JSON");
97
98    let edits_schema: serde_json::Value = serde_json::json!({
99        "type": "array",
100        "items": {
101            "type": "object",
102            "properties": {
103                "edit_type": {"type": "string", "enum": ["add", "remove", "reword", "move"]},
104                "original_text": {"type": "string"},
105                "suggested_content": {"type": "string"},
106                "target_section": {"type": "string"},
107                "reasoning": {"type": "string"}
108            },
109            "required": ["edit_type", "reasoning"],
110            "additionalProperties": false
111        }
112    });
113
114    schema["properties"]["claude_md_edits"] = edits_schema;
115
116    serde_json::to_string_pretty(&schema).expect("schema serialization cannot fail")
117}
118
119/// Run analysis: re-parse sessions, scrub, call AI, merge patterns, store results.
120///
121/// `on_batch_start` is called before each AI call with (batch_index, total_batches, session_count, prompt_chars).
122pub fn analyze<F>(
123    conn: &Connection,
124    config: &Config,
125    project: Option<&str>,
126    window_days: u32,
127    on_batch_start: F,
128) -> Result<AnalyzeResult, CoreError>
129where
130    F: Fn(usize, usize, usize, usize),
131{
132    // Check claude CLI availability and auth
133    if !ClaudeCliBackend::is_available() {
134        return Err(CoreError::Analysis(
135            "claude CLI not found on PATH. Install Claude Code CLI to use analysis.".to_string(),
136        ));
137    }
138    // Pre-flight auth check: a minimal prompt without --json-schema returns immediately
139    // on auth failure. With --json-schema, auth errors cause an infinite StructuredOutput
140    // retry loop in the CLI (it keeps injecting "You MUST call StructuredOutput" but the
141    // auth error response is always plain text, never a tool call).
142    ClaudeCliBackend::check_auth()?;
143
144    let since = Utc::now() - Duration::days(window_days as i64);
145
146    // Get sessions to analyze — rolling_window=true re-analyzes all sessions in window,
147    // false only picks up sessions not yet analyzed.
148    let rolling = config.analysis.rolling_window;
149    let sessions_to_analyze = db::get_sessions_for_analysis(conn, project, &since, rolling)?;
150
151    if sessions_to_analyze.is_empty() {
152        return Ok(AnalyzeResult {
153            sessions_analyzed: 0,
154            new_patterns: 0,
155            updated_patterns: 0,
156            total_patterns: 0,
157            input_tokens: 0,
158            output_tokens: 0,
159            batch_details: Vec::new(),
160        });
161    }
162
163    // Re-parse session files from disk to get full content
164    let mut parsed_sessions = Vec::new();
165    for ingested in &sessions_to_analyze {
166        let path = Path::new(&ingested.session_path);
167        if !path.exists() {
168            eprintln!(
169                "warning: session file not found: {}",
170                ingested.session_path
171            );
172            continue;
173        }
174
175        match session::parse_session_file(path, &ingested.session_id, &ingested.project) {
176            Ok(mut s) => {
177                // Apply secret scrubbing if enabled
178                if config.privacy.scrub_secrets {
179                    scrub::scrub_session(&mut s);
180                }
181                parsed_sessions.push(s);
182            }
183            Err(e) => {
184                eprintln!(
185                    "warning: failed to re-parse session {}: {e}",
186                    ingested.session_id
187                );
188            }
189        }
190    }
191
192    // Filter out low-signal sessions: single-message sessions are typically
193    // programmatic `claude -p` calls (including retro's own analysis) or heavily
194    // compacted sessions — not real multi-turn conversations with discoverable patterns.
195    let before_filter = parsed_sessions.len();
196    parsed_sessions.retain(|s| s.user_messages.len() >= 2);
197    let filtered_out = before_filter - parsed_sessions.len();
198    if filtered_out > 0 {
199        eprintln!(
200            "  Skipped {} single-message session{} (no pattern signal)",
201            filtered_out,
202            if filtered_out == 1 { "" } else { "s" }
203        );
204    }
205
206    let analyzed_count = parsed_sessions.len();
207
208    if parsed_sessions.is_empty() {
209        // Still record all sessions as analyzed so we don't re-process low-signal ones
210        for ingested in &sessions_to_analyze {
211            db::record_analyzed_session(conn, &ingested.session_id, &ingested.project)?;
212        }
213        return Ok(AnalyzeResult {
214            sessions_analyzed: 0,
215            new_patterns: 0,
216            updated_patterns: 0,
217            total_patterns: 0,
218            input_tokens: 0,
219            output_tokens: 0,
220            batch_details: Vec::new(),
221        });
222    }
223
224    // Load context summary (best-effort — analysis proceeds without it)
225    let context_summary = match project {
226        Some(project_path) => context::snapshot_context(config, project_path)
227            .ok()
228            .map(|s| prompts::build_context_summary(&s))
229            .filter(|s| !s.is_empty()),
230        None => None,
231    };
232
233    // Create AI backend
234    let backend = ClaudeCliBackend::new(&config.ai);
235
236    let mut total_input_tokens: u64 = 0;
237    let mut total_output_tokens: u64 = 0;
238    let mut new_count = 0;
239    let mut update_count = 0;
240    let mut batch_details: Vec<BatchDetail> = Vec::new();
241
242    // Process in batches
243    let total_batches = (parsed_sessions.len() + BATCH_SIZE - 1) / BATCH_SIZE;
244
245    for (batch_idx, batch) in parsed_sessions.chunks(BATCH_SIZE).enumerate() {
246        // Reload existing patterns before each batch (picks up patterns from prior batches)
247        let existing = db::get_patterns(conn, &["discovered", "active"], project)?;
248
249        // Build prompt — pass full_management flag to include CLAUDE.md editing instructions
250        let full_mgmt = config.claude_md.full_management;
251        let prompt = prompts::build_analysis_prompt(batch, &existing, context_summary.as_deref(), full_mgmt);
252        let prompt_chars = prompt.len();
253
254        on_batch_start(batch_idx, total_batches, batch.len(), prompt_chars);
255
256        // Choose schema based on full_management config — extended schema includes claude_md_edits
257        let schema_string;
258        let schema: &str = if full_mgmt {
259            schema_string = full_management_analysis_schema();
260            &schema_string
261        } else {
262            ANALYSIS_RESPONSE_SCHEMA
263        };
264
265        // Call AI backend
266        let response = backend.execute(&prompt, Some(schema))?;
267        total_input_tokens += response.input_tokens;
268        total_output_tokens += response.output_tokens;
269
270        // Parse AI response into AnalysisResponse (reasoning + pattern updates)
271        let analysis_resp = parse_analysis_response(&response.text).map_err(|e| {
272            CoreError::Analysis(format!(
273                "{e}\n(prompt_chars={}, output_tokens={}, result_chars={})",
274                prompt_chars,
275                response.output_tokens,
276                response.text.len()
277            ))
278        })?;
279
280        let reasoning = analysis_resp.reasoning;
281        let claude_md_edits = analysis_resp.claude_md_edits;
282
283        // Apply merge logic
284        let (new_patterns, merge_updates) =
285            merge::process_updates(analysis_resp.patterns, &existing, project);
286
287        let mut batch_new = new_patterns.len();
288        let batch_updated = merge_updates.len();
289
290        // Store new patterns
291        for pattern in &new_patterns {
292            db::insert_pattern(conn, pattern)?;
293            new_count += 1;
294        }
295
296        // Apply merge updates
297        for update in &merge_updates {
298            db::update_pattern_merge(
299                conn,
300                &update.pattern_id,
301                &update.new_sessions,
302                update.new_confidence,
303                Utc::now(),
304                update.additional_times_seen,
305            )?;
306            update_count += 1;
307        }
308
309        // Store claude_md_edits as patterns (with RedundantContext type and ClaudeMd target)
310        for edit in &claude_md_edits {
311            let edit_json = serde_json::json!({
312                "edit_type": edit.edit_type.to_string(),
313                "original": edit.original_text,
314                "replacement": edit.suggested_content,
315                "target_section": edit.target_section,
316                "reasoning": edit.reasoning,
317            });
318
319            let description = format!(
320                "[edit:{}] {}",
321                edit.edit_type,
322                edit.original_text
323            );
324
325            let now = Utc::now();
326            let pattern = Pattern {
327                id: uuid::Uuid::new_v4().to_string(),
328                pattern_type: PatternType::RedundantContext,
329                description,
330                confidence: 0.75,
331                times_seen: 1,
332                first_seen: now,
333                last_seen: now,
334                last_projected: None,
335                status: PatternStatus::Discovered,
336                source_sessions: batch.iter().map(|s| s.session_id.clone()).collect(),
337                related_files: Vec::new(),
338                suggested_content: edit_json.to_string(),
339                suggested_target: SuggestedTarget::ClaudeMd,
340                project: project.map(String::from),
341                generation_failed: false,
342            };
343
344            db::insert_pattern(conn, &pattern)?;
345            new_count += 1;
346            batch_new += 1;
347        }
348
349        // Collect per-batch diagnostics
350        let preview = truncate_for_error(&response.text, 500).to_string();
351        batch_details.push(BatchDetail {
352            batch_index: batch_idx,
353            session_count: batch.len(),
354            session_ids: batch.iter().map(|s| s.session_id.clone()).collect(),
355            prompt_chars,
356            input_tokens: response.input_tokens,
357            output_tokens: response.output_tokens,
358            new_patterns: batch_new,
359            updated_patterns: batch_updated,
360            reasoning,
361            ai_response_preview: preview,
362        });
363    }
364
365    // Record all sessions as analyzed
366    for ingested in &sessions_to_analyze {
367        db::record_analyzed_session(conn, &ingested.session_id, &ingested.project)?;
368    }
369
370    // Get total pattern count
371    let discovered = db::pattern_count_by_status(conn, "discovered")?;
372    let active = db::pattern_count_by_status(conn, "active")?;
373
374    Ok(AnalyzeResult {
375        sessions_analyzed: analyzed_count,
376        new_patterns: new_count,
377        updated_patterns: update_count,
378        total_patterns: (discovered + active) as usize,
379        input_tokens: total_input_tokens,
380        output_tokens: total_output_tokens,
381        batch_details,
382    })
383}
384
385/// v2 analysis: re-parse sessions, scrub, call AI with graph prompt/schema, write to knowledge graph.
386///
387/// `on_batch_start` is called before each AI call with (batch_index, total_batches, session_count, prompt_chars).
388pub fn analyze_v2<F>(
389    conn: &Connection,
390    config: &Config,
391    project: Option<&str>,
392    window_days: u32,
393    on_batch_start: F,
394) -> Result<AnalyzeV2Result, CoreError>
395where
396    F: Fn(usize, usize, usize, usize),
397{
398    // Check claude CLI availability and auth
399    if !ClaudeCliBackend::is_available() {
400        return Err(CoreError::Analysis(
401            "claude CLI not found on PATH. Install Claude Code CLI to use analysis.".to_string(),
402        ));
403    }
404    ClaudeCliBackend::check_auth()?;
405
406    let since = Utc::now() - Duration::days(window_days as i64);
407
408    // Get sessions to analyze — rolling_window=true re-analyzes all sessions in window,
409    // false only picks up sessions not yet analyzed.
410    let rolling = config.analysis.rolling_window;
411    let sessions_to_analyze = db::get_sessions_for_analysis(conn, project, &since, rolling)?;
412
413    if sessions_to_analyze.is_empty() {
414        return Ok(AnalyzeV2Result {
415            sessions_analyzed: 0,
416            nodes_created: 0,
417            nodes_updated: 0,
418            edges_created: 0,
419            nodes_merged: 0,
420            input_tokens: 0,
421            output_tokens: 0,
422            batch_count: 0,
423        });
424    }
425
426    // Re-parse session files from disk to get full content
427    let mut parsed_sessions = Vec::new();
428    for ingested in &sessions_to_analyze {
429        let path = Path::new(&ingested.session_path);
430        if !path.exists() {
431            eprintln!(
432                "warning: session file not found: {}",
433                ingested.session_path
434            );
435            continue;
436        }
437
438        match session::parse_session_file(path, &ingested.session_id, &ingested.project) {
439            Ok(mut s) => {
440                if config.privacy.scrub_secrets {
441                    scrub::scrub_session(&mut s);
442                }
443                parsed_sessions.push(s);
444            }
445            Err(e) => {
446                eprintln!(
447                    "warning: failed to re-parse session {}: {e}",
448                    ingested.session_id
449                );
450            }
451        }
452    }
453
454    // Filter out low-signal sessions (single-message = programmatic claude -p calls)
455    let before_filter = parsed_sessions.len();
456    parsed_sessions.retain(|s| s.user_messages.len() >= 2);
457    let filtered_out = before_filter - parsed_sessions.len();
458    if filtered_out > 0 {
459        eprintln!(
460            "  Skipped {} single-message session{} (no pattern signal)",
461            filtered_out,
462            if filtered_out == 1 { "" } else { "s" }
463        );
464    }
465
466    let analyzed_count = parsed_sessions.len();
467
468    if parsed_sessions.is_empty() {
469        // Still record all sessions as analyzed so we don't re-process low-signal ones
470        for ingested in &sessions_to_analyze {
471            db::record_analyzed_session(conn, &ingested.session_id, &ingested.project)?;
472        }
473        return Ok(AnalyzeV2Result {
474            sessions_analyzed: 0,
475            nodes_created: 0,
476            nodes_updated: 0,
477            edges_created: 0,
478            nodes_merged: 0,
479            input_tokens: 0,
480            output_tokens: 0,
481            batch_count: 0,
482        });
483    }
484
485    // Convert parsed sessions to compact format for the prompt
486    let compact_sessions: Vec<_> = parsed_sessions
487        .iter()
488        .map(prompts::to_compact_session)
489        .collect();
490
491    // Load existing knowledge nodes for dedup context
492    let existing_nodes = db::get_nodes_by_status(conn, &NodeStatus::Active).unwrap_or_default();
493
494    // Resolve project slug for the prompt
495    let project_slug = project.map(db::generate_project_slug);
496
497    // Create AI backend
498    let backend = ClaudeCliBackend::new(&config.ai);
499
500    let mut total_input_tokens: u64 = 0;
501    let mut total_output_tokens: u64 = 0;
502    let mut total_nodes_created: usize = 0;
503    let mut total_nodes_updated: usize = 0;
504    let mut total_edges_created: usize = 0;
505    let mut total_nodes_merged: usize = 0;
506
507    // Process in batches
508    let total_batches = (compact_sessions.len() + BATCH_SIZE - 1) / BATCH_SIZE;
509    let mut batch_count: usize = 0;
510
511    for (batch_idx, batch) in compact_sessions.chunks(BATCH_SIZE).enumerate() {
512        // Build v2 prompt
513        let prompt = prompts::build_graph_analysis_prompt(
514            batch,
515            &existing_nodes,
516            project_slug.as_deref(),
517        );
518        let prompt_chars = prompt.len();
519
520        on_batch_start(batch_idx, total_batches, batch.len(), prompt_chars);
521
522        // Call AI with v2 schema
523        let response = backend.execute(&prompt, Some(GRAPH_ANALYSIS_RESPONSE_SCHEMA))?;
524        total_input_tokens += response.input_tokens;
525        total_output_tokens += response.output_tokens;
526
527        // Parse response into graph operations
528        let ops = parse_graph_response(&response.text, project_slug.as_deref()).map_err(|e| {
529            CoreError::Analysis(format!(
530                "{e}\n(prompt_chars={}, output_tokens={}, result_chars={})",
531                prompt_chars,
532                response.output_tokens,
533                response.text.len()
534            ))
535        })?;
536
537        // Apply to knowledge graph
538        let graph_result = db::apply_graph_operations(conn, &ops)?;
539        total_nodes_created += graph_result.nodes_created;
540        total_nodes_updated += graph_result.nodes_updated;
541        total_edges_created += graph_result.edges_created;
542        total_nodes_merged += graph_result.nodes_merged;
543        batch_count += 1;
544    }
545
546    // Record all sessions as analyzed
547    for ingested in &sessions_to_analyze {
548        db::record_analyzed_session(conn, &ingested.session_id, &ingested.project)?;
549    }
550
551    Ok(AnalyzeV2Result {
552        sessions_analyzed: analyzed_count,
553        nodes_created: total_nodes_created,
554        nodes_updated: total_nodes_updated,
555        edges_created: total_edges_created,
556        nodes_merged: total_nodes_merged,
557        input_tokens: total_input_tokens,
558        output_tokens: total_output_tokens,
559        batch_count,
560    })
561}
562
563/// Parse the AI response text into an AnalysisResponse (reasoning + pattern updates).
564/// With `--json-schema` constrained decoding, the response is guaranteed valid JSON.
565fn parse_analysis_response(text: &str) -> Result<AnalysisResponse, CoreError> {
566    let trimmed = text.trim();
567    let response: AnalysisResponse = serde_json::from_str(trimmed).map_err(|e| {
568        CoreError::Analysis(format!(
569            "failed to parse AI response as JSON: {e}\nresponse text: {}",
570            truncate_for_error(text, 1500)
571        ))
572    })?;
573    Ok(response)
574}
575
576/// Parse an AI response into a GraphOperation batch.
577pub fn parse_graph_response(json: &str, default_project: Option<&str>) -> Result<Vec<GraphOperation>, CoreError> {
578    let response: GraphAnalysisResponse = serde_json::from_str(json)
579        .map_err(|e| CoreError::Parse(format!("failed to parse graph analysis response: {e}")))?;
580
581    let mut ops = Vec::new();
582    for op_resp in &response.operations {
583        match op_resp.action.as_str() {
584            "create_node" => {
585                let node_type = op_resp.node_type.as_deref()
586                    .map(NodeType::from_str)
587                    .unwrap_or(NodeType::Pattern);
588                let scope = op_resp.scope.as_deref()
589                    .map(NodeScope::from_str)
590                    .unwrap_or(NodeScope::Project);
591                let project_id = match scope {
592                    NodeScope::Global => None,
593                    NodeScope::Project => op_resp.project_id.clone()
594                        .or_else(|| default_project.map(String::from)),
595                };
596                ops.push(GraphOperation::CreateNode {
597                    node_type,
598                    scope,
599                    project_id,
600                    content: op_resp.content.clone().unwrap_or_default(),
601                    confidence: op_resp.confidence.unwrap_or(0.5),
602                });
603            }
604            "update_node" => {
605                if let Some(id) = &op_resp.node_id {
606                    ops.push(GraphOperation::UpdateNode {
607                        id: id.clone(),
608                        confidence: op_resp.new_confidence,
609                        content: op_resp.new_content.clone(),
610                    });
611                }
612            }
613            "create_edge" => {
614                if let (Some(source), Some(target)) = (&op_resp.source_id, &op_resp.target_id) {
615                    let edge_type = op_resp.edge_type.as_deref()
616                        .and_then(EdgeType::from_str)
617                        .unwrap_or(EdgeType::Supports);
618                    ops.push(GraphOperation::CreateEdge {
619                        source_id: source.clone(),
620                        target_id: target.clone(),
621                        edge_type,
622                    });
623                }
624            }
625            "merge_nodes" => {
626                if let (Some(keep), Some(remove)) = (&op_resp.keep_id, &op_resp.remove_id) {
627                    ops.push(GraphOperation::MergeNodes {
628                        keep_id: keep.clone(),
629                        remove_id: remove.clone(),
630                    });
631                }
632            }
633            _ => {} // Skip unknown actions
634        }
635    }
636    Ok(ops)
637}
638
639fn truncate_for_error(s: &str, max: usize) -> &str {
640    if s.len() <= max {
641        s
642    } else {
643        let mut i = max;
644        while i > 0 && !s.is_char_boundary(i) {
645            i -= 1;
646        }
647        &s[..i]
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use crate::models::PatternUpdate;
655
656    #[test]
657    fn test_parse_analysis_response_json() {
658        let json = r#"{
659            "reasoning": "Found recurring instruction across sessions.",
660            "patterns": [
661                {
662                    "action": "new",
663                    "pattern_type": "repetitive_instruction",
664                    "description": "User always asks to use uv",
665                    "confidence": 0.85,
666                    "source_sessions": ["sess-1"],
667                    "related_files": [],
668                    "suggested_content": "Always use uv",
669                    "suggested_target": "claude_md"
670                },
671                {
672                    "action": "update",
673                    "existing_id": "pat-123",
674                    "new_sessions": ["sess-2"],
675                    "new_confidence": 0.92
676                }
677            ]
678        }"#;
679
680        let resp = parse_analysis_response(json).unwrap();
681        assert_eq!(resp.reasoning, "Found recurring instruction across sessions.");
682        assert_eq!(resp.patterns.len(), 2);
683        assert!(matches!(&resp.patterns[0], PatternUpdate::New(_)));
684        assert!(matches!(&resp.patterns[1], PatternUpdate::Update(_)));
685    }
686
687    #[test]
688    fn test_parse_analysis_response_null_fields() {
689        let json = r#"{
690            "reasoning": "Observed a single pattern.",
691            "patterns": [
692                {
693                    "action": "new",
694                    "pattern_type": "repetitive_instruction",
695                    "description": "Some pattern",
696                    "confidence": 0.8,
697                    "source_sessions": [],
698                    "related_files": [],
699                    "suggested_content": null,
700                    "suggested_target": "claude_md"
701                }
702            ]
703        }"#;
704        let resp = parse_analysis_response(json).unwrap();
705        assert_eq!(resp.patterns.len(), 1);
706        if let PatternUpdate::New(ref p) = resp.patterns[0] {
707            assert_eq!(p.suggested_content, "");
708        } else {
709            panic!("expected New pattern");
710        }
711    }
712
713    #[test]
714    fn test_parse_analysis_response_empty() {
715        let json = r#"{"reasoning": "No recurring patterns found.", "patterns": []}"#;
716        let resp = parse_analysis_response(json).unwrap();
717        assert_eq!(resp.reasoning, "No recurring patterns found.");
718        assert!(resp.patterns.is_empty());
719    }
720
721    #[test]
722    fn test_parse_analysis_response_missing_reasoning_defaults_empty() {
723        let json = r#"{"patterns": []}"#;
724        let resp = parse_analysis_response(json).unwrap();
725        assert_eq!(resp.reasoning, "");
726        assert!(resp.patterns.is_empty());
727    }
728
729    #[test]
730    fn test_parse_analysis_response_pure_prose_fails() {
731        let text = "I analyzed the sessions but found no recurring patterns worth reporting.";
732        let result = parse_analysis_response(text);
733        assert!(result.is_err());
734    }
735
736    #[test]
737    fn test_analysis_response_schema_is_valid_json() {
738        let value: serde_json::Value = serde_json::from_str(ANALYSIS_RESPONSE_SCHEMA)
739            .expect("ANALYSIS_RESPONSE_SCHEMA must be valid JSON");
740        assert_eq!(value["type"], "object");
741        assert!(value["properties"]["patterns"].is_object());
742    }
743
744    #[test]
745    fn test_full_management_analysis_schema_is_valid_json() {
746        let schema_str = full_management_analysis_schema();
747        let value: serde_json::Value =
748            serde_json::from_str(&schema_str).expect("full_management schema must be valid JSON");
749        assert_eq!(value["type"], "object");
750        assert!(value["properties"]["patterns"].is_object());
751    }
752
753    #[test]
754    fn test_full_management_analysis_schema_contains_claude_md_edits() {
755        let schema_str = full_management_analysis_schema();
756        let value: serde_json::Value = serde_json::from_str(&schema_str).unwrap();
757
758        // claude_md_edits should be in properties
759        let edits = &value["properties"]["claude_md_edits"];
760        assert!(edits.is_object(), "claude_md_edits should be in properties");
761        assert_eq!(edits["type"], "array");
762
763        // Items should have edit_type, reasoning as required
764        let items = &edits["items"];
765        assert_eq!(items["type"], "object");
766        let required: Vec<String> = items["required"]
767            .as_array()
768            .unwrap()
769            .iter()
770            .map(|v| v.as_str().unwrap().to_string())
771            .collect();
772        assert!(required.contains(&"edit_type".to_string()));
773        assert!(required.contains(&"reasoning".to_string()));
774
775        // edit_type should have the right enum values
776        let edit_type_enum = items["properties"]["edit_type"]["enum"]
777            .as_array()
778            .unwrap();
779        let enum_values: Vec<&str> = edit_type_enum.iter().map(|v| v.as_str().unwrap()).collect();
780        assert!(enum_values.contains(&"add"));
781        assert!(enum_values.contains(&"remove"));
782        assert!(enum_values.contains(&"reword"));
783        assert!(enum_values.contains(&"move"));
784
785        // additionalProperties should be false on items
786        assert_eq!(items["additionalProperties"], false);
787    }
788
789    #[test]
790    fn test_full_management_schema_claude_md_edits_not_required() {
791        let schema_str = full_management_analysis_schema();
792        let value: serde_json::Value = serde_json::from_str(&schema_str).unwrap();
793
794        // claude_md_edits should NOT be in the top-level required array
795        let required: Vec<String> = value["required"]
796            .as_array()
797            .unwrap()
798            .iter()
799            .map(|v| v.as_str().unwrap().to_string())
800            .collect();
801        assert!(
802            !required.contains(&"claude_md_edits".to_string()),
803            "claude_md_edits should NOT be in top-level required"
804        );
805        // But reasoning and patterns should still be required
806        assert!(required.contains(&"reasoning".to_string()));
807        assert!(required.contains(&"patterns".to_string()));
808    }
809
810    #[test]
811    fn test_full_management_schema_preserves_base_patterns() {
812        // The full_management schema should have the same patterns structure as the base schema
813        let base: serde_json::Value = serde_json::from_str(ANALYSIS_RESPONSE_SCHEMA).unwrap();
814        let full: serde_json::Value =
815            serde_json::from_str(&full_management_analysis_schema()).unwrap();
816
817        assert_eq!(
818            base["properties"]["patterns"],
819            full["properties"]["patterns"],
820            "patterns schema should be identical between base and full_management"
821        );
822        assert_eq!(
823            base["properties"]["reasoning"],
824            full["properties"]["reasoning"],
825            "reasoning schema should be identical"
826        );
827    }
828
829    #[test]
830    fn test_graph_analysis_schema_is_valid_json() {
831        let _: serde_json::Value = serde_json::from_str(GRAPH_ANALYSIS_RESPONSE_SCHEMA)
832            .expect("schema must be valid JSON");
833    }
834
835    #[test]
836    fn test_parse_graph_response() {
837        let json = r#"{
838            "reasoning": "Found testing pattern",
839            "operations": [
840                {
841                    "action": "create_node",
842                    "node_type": "rule",
843                    "scope": "project",
844                    "content": "Always run tests",
845                    "confidence": 0.85
846                },
847                {
848                    "action": "update_node",
849                    "node_id": "existing-1",
850                    "new_confidence": 0.9
851                }
852            ]
853        }"#;
854        let ops = parse_graph_response(json, Some("my-app")).unwrap();
855        assert_eq!(ops.len(), 2);
856        match &ops[0] {
857            GraphOperation::CreateNode { content, scope, .. } => {
858                assert_eq!(content, "Always run tests");
859                assert_eq!(*scope, NodeScope::Project);
860            }
861            _ => panic!("Expected CreateNode"),
862        }
863        match &ops[1] {
864            GraphOperation::UpdateNode { id, confidence, .. } => {
865                assert_eq!(id, "existing-1");
866                assert_eq!(*confidence, Some(0.9));
867            }
868            _ => panic!("Expected UpdateNode"),
869        }
870    }
871}