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).
388/// `max_batches` limits the number of AI calls (for budget enforcement). 0 means unlimited.
389pub fn analyze_v2<F>(
390    conn: &Connection,
391    config: &Config,
392    project: Option<&str>,
393    window_days: u32,
394    max_batches: usize,
395    on_batch_start: F,
396) -> Result<AnalyzeV2Result, CoreError>
397where
398    F: Fn(usize, usize, usize, usize),
399{
400    // Check claude CLI availability and auth
401    if !ClaudeCliBackend::is_available() {
402        return Err(CoreError::Analysis(
403            "claude CLI not found on PATH. Install Claude Code CLI to use analysis.".to_string(),
404        ));
405    }
406    ClaudeCliBackend::check_auth()?;
407
408    let since = Utc::now() - Duration::days(window_days as i64);
409
410    // Get sessions to analyze — rolling_window=true re-analyzes all sessions in window,
411    // false only picks up sessions not yet analyzed.
412    let rolling = config.analysis.rolling_window;
413    let sessions_to_analyze = db::get_sessions_for_analysis(conn, project, &since, rolling)?;
414
415    if sessions_to_analyze.is_empty() {
416        return Ok(AnalyzeV2Result {
417            sessions_analyzed: 0,
418            nodes_created: 0,
419            nodes_updated: 0,
420            edges_created: 0,
421            nodes_merged: 0,
422            input_tokens: 0,
423            output_tokens: 0,
424            batch_count: 0,
425        });
426    }
427
428    // Re-parse session files from disk to get full content
429    let mut parsed_sessions = Vec::new();
430    for ingested in &sessions_to_analyze {
431        let path = Path::new(&ingested.session_path);
432        if !path.exists() {
433            eprintln!(
434                "warning: session file not found: {}",
435                ingested.session_path
436            );
437            continue;
438        }
439
440        match session::parse_session_file(path, &ingested.session_id, &ingested.project) {
441            Ok(mut s) => {
442                if config.privacy.scrub_secrets {
443                    scrub::scrub_session(&mut s);
444                }
445                parsed_sessions.push(s);
446            }
447            Err(e) => {
448                eprintln!(
449                    "warning: failed to re-parse session {}: {e}",
450                    ingested.session_id
451                );
452            }
453        }
454    }
455
456    // Filter out low-signal sessions (single-message = programmatic claude -p calls)
457    let before_filter = parsed_sessions.len();
458    parsed_sessions.retain(|s| s.user_messages.len() >= 2);
459    let filtered_out = before_filter - parsed_sessions.len();
460    if filtered_out > 0 {
461        eprintln!(
462            "  Skipped {} single-message session{} (no pattern signal)",
463            filtered_out,
464            if filtered_out == 1 { "" } else { "s" }
465        );
466    }
467
468    let analyzed_count = parsed_sessions.len();
469
470    if parsed_sessions.is_empty() {
471        // Still record all sessions as analyzed so we don't re-process low-signal ones
472        for ingested in &sessions_to_analyze {
473            db::record_analyzed_session(conn, &ingested.session_id, &ingested.project)?;
474        }
475        return Ok(AnalyzeV2Result {
476            sessions_analyzed: 0,
477            nodes_created: 0,
478            nodes_updated: 0,
479            edges_created: 0,
480            nodes_merged: 0,
481            input_tokens: 0,
482            output_tokens: 0,
483            batch_count: 0,
484        });
485    }
486
487    // Convert parsed sessions to compact format for the prompt
488    let compact_sessions: Vec<_> = parsed_sessions
489        .iter()
490        .map(prompts::to_compact_session)
491        .collect();
492
493    // Load existing knowledge nodes for dedup context
494    let existing_nodes = db::get_nodes_by_status(conn, &NodeStatus::Active).unwrap_or_default();
495
496    // Resolve project slug for the prompt
497    let project_slug = project.map(db::generate_project_slug);
498
499    // Create AI backend
500    let backend = ClaudeCliBackend::new(&config.ai);
501
502    let mut total_input_tokens: u64 = 0;
503    let mut total_output_tokens: u64 = 0;
504    let mut total_nodes_created: usize = 0;
505    let mut total_nodes_updated: usize = 0;
506    let mut total_edges_created: usize = 0;
507    let mut total_nodes_merged: usize = 0;
508
509    // Process in batches (limited by max_batches for budget enforcement)
510    let total_batches = (compact_sessions.len() + BATCH_SIZE - 1) / BATCH_SIZE;
511    let effective_batches = if max_batches == 0 { total_batches } else { total_batches.min(max_batches) };
512    let mut batch_count: usize = 0;
513
514    for (batch_idx, batch) in compact_sessions.chunks(BATCH_SIZE).enumerate() {
515        if batch_idx >= effective_batches {
516            break;
517        }
518        // Build v2 prompt
519        let prompt = prompts::build_graph_analysis_prompt(
520            batch,
521            &existing_nodes,
522            project_slug.as_deref(),
523        );
524        let prompt_chars = prompt.len();
525
526        on_batch_start(batch_idx, total_batches, batch.len(), prompt_chars);
527
528        // Call AI with v2 schema
529        let response = backend.execute(&prompt, Some(GRAPH_ANALYSIS_RESPONSE_SCHEMA))?;
530        total_input_tokens += response.input_tokens;
531        total_output_tokens += response.output_tokens;
532
533        // Parse response into graph operations
534        let ops = parse_graph_response(&response.text, project_slug.as_deref()).map_err(|e| {
535            CoreError::Analysis(format!(
536                "{e}\n(prompt_chars={}, output_tokens={}, result_chars={})",
537                prompt_chars,
538                response.output_tokens,
539                response.text.len()
540            ))
541        })?;
542
543        // Apply to knowledge graph
544        let graph_result = db::apply_graph_operations(conn, &ops)?;
545        total_nodes_created += graph_result.nodes_created;
546        total_nodes_updated += graph_result.nodes_updated;
547        total_edges_created += graph_result.edges_created;
548        total_nodes_merged += graph_result.nodes_merged;
549        batch_count += 1;
550    }
551
552    // Record all sessions as analyzed
553    for ingested in &sessions_to_analyze {
554        db::record_analyzed_session(conn, &ingested.session_id, &ingested.project)?;
555    }
556
557    Ok(AnalyzeV2Result {
558        sessions_analyzed: analyzed_count,
559        nodes_created: total_nodes_created,
560        nodes_updated: total_nodes_updated,
561        edges_created: total_edges_created,
562        nodes_merged: total_nodes_merged,
563        input_tokens: total_input_tokens,
564        output_tokens: total_output_tokens,
565        batch_count,
566    })
567}
568
569/// Parse the AI response text into an AnalysisResponse (reasoning + pattern updates).
570/// With `--json-schema` constrained decoding, the response is guaranteed valid JSON.
571fn parse_analysis_response(text: &str) -> Result<AnalysisResponse, CoreError> {
572    let trimmed = text.trim();
573    let response: AnalysisResponse = serde_json::from_str(trimmed).map_err(|e| {
574        CoreError::Analysis(format!(
575            "failed to parse AI response as JSON: {e}\nresponse text: {}",
576            truncate_for_error(text, 1500)
577        ))
578    })?;
579    Ok(response)
580}
581
582/// Parse an AI response into a GraphOperation batch.
583pub fn parse_graph_response(json: &str, default_project: Option<&str>) -> Result<Vec<GraphOperation>, CoreError> {
584    let response: GraphAnalysisResponse = serde_json::from_str(json)
585        .map_err(|e| CoreError::Parse(format!("failed to parse graph analysis response: {e}")))?;
586
587    let mut ops = Vec::new();
588    for op_resp in &response.operations {
589        match op_resp.action.as_str() {
590            "create_node" => {
591                let node_type = op_resp.node_type.as_deref()
592                    .map(NodeType::from_str)
593                    .unwrap_or(NodeType::Pattern);
594                let scope = op_resp.scope.as_deref()
595                    .map(NodeScope::from_str)
596                    .unwrap_or(NodeScope::Project);
597                let project_id = match scope {
598                    NodeScope::Global => None,
599                    NodeScope::Project => op_resp.project_id.clone()
600                        .or_else(|| default_project.map(String::from)),
601                };
602                ops.push(GraphOperation::CreateNode {
603                    node_type,
604                    scope,
605                    project_id,
606                    content: op_resp.content.clone().unwrap_or_default(),
607                    confidence: op_resp.confidence.unwrap_or(0.5),
608                });
609            }
610            "update_node" => {
611                if let Some(id) = &op_resp.node_id {
612                    ops.push(GraphOperation::UpdateNode {
613                        id: id.clone(),
614                        confidence: op_resp.new_confidence,
615                        content: op_resp.new_content.clone(),
616                    });
617                }
618            }
619            "create_edge" => {
620                if let (Some(source), Some(target)) = (&op_resp.source_id, &op_resp.target_id) {
621                    let edge_type = op_resp.edge_type.as_deref()
622                        .and_then(EdgeType::from_str)
623                        .unwrap_or(EdgeType::Supports);
624                    ops.push(GraphOperation::CreateEdge {
625                        source_id: source.clone(),
626                        target_id: target.clone(),
627                        edge_type,
628                    });
629                }
630            }
631            "merge_nodes" => {
632                if let (Some(keep), Some(remove)) = (&op_resp.keep_id, &op_resp.remove_id) {
633                    ops.push(GraphOperation::MergeNodes {
634                        keep_id: keep.clone(),
635                        remove_id: remove.clone(),
636                    });
637                }
638            }
639            _ => {} // Skip unknown actions
640        }
641    }
642    Ok(ops)
643}
644
645fn truncate_for_error(s: &str, max: usize) -> &str {
646    if s.len() <= max {
647        s
648    } else {
649        let mut i = max;
650        while i > 0 && !s.is_char_boundary(i) {
651            i -= 1;
652        }
653        &s[..i]
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use crate::models::PatternUpdate;
661
662    #[test]
663    fn test_parse_analysis_response_json() {
664        let json = r#"{
665            "reasoning": "Found recurring instruction across sessions.",
666            "patterns": [
667                {
668                    "action": "new",
669                    "pattern_type": "repetitive_instruction",
670                    "description": "User always asks to use uv",
671                    "confidence": 0.85,
672                    "source_sessions": ["sess-1"],
673                    "related_files": [],
674                    "suggested_content": "Always use uv",
675                    "suggested_target": "claude_md"
676                },
677                {
678                    "action": "update",
679                    "existing_id": "pat-123",
680                    "new_sessions": ["sess-2"],
681                    "new_confidence": 0.92
682                }
683            ]
684        }"#;
685
686        let resp = parse_analysis_response(json).unwrap();
687        assert_eq!(resp.reasoning, "Found recurring instruction across sessions.");
688        assert_eq!(resp.patterns.len(), 2);
689        assert!(matches!(&resp.patterns[0], PatternUpdate::New(_)));
690        assert!(matches!(&resp.patterns[1], PatternUpdate::Update(_)));
691    }
692
693    #[test]
694    fn test_parse_analysis_response_null_fields() {
695        let json = r#"{
696            "reasoning": "Observed a single pattern.",
697            "patterns": [
698                {
699                    "action": "new",
700                    "pattern_type": "repetitive_instruction",
701                    "description": "Some pattern",
702                    "confidence": 0.8,
703                    "source_sessions": [],
704                    "related_files": [],
705                    "suggested_content": null,
706                    "suggested_target": "claude_md"
707                }
708            ]
709        }"#;
710        let resp = parse_analysis_response(json).unwrap();
711        assert_eq!(resp.patterns.len(), 1);
712        if let PatternUpdate::New(ref p) = resp.patterns[0] {
713            assert_eq!(p.suggested_content, "");
714        } else {
715            panic!("expected New pattern");
716        }
717    }
718
719    #[test]
720    fn test_parse_analysis_response_empty() {
721        let json = r#"{"reasoning": "No recurring patterns found.", "patterns": []}"#;
722        let resp = parse_analysis_response(json).unwrap();
723        assert_eq!(resp.reasoning, "No recurring patterns found.");
724        assert!(resp.patterns.is_empty());
725    }
726
727    #[test]
728    fn test_parse_analysis_response_missing_reasoning_defaults_empty() {
729        let json = r#"{"patterns": []}"#;
730        let resp = parse_analysis_response(json).unwrap();
731        assert_eq!(resp.reasoning, "");
732        assert!(resp.patterns.is_empty());
733    }
734
735    #[test]
736    fn test_parse_analysis_response_pure_prose_fails() {
737        let text = "I analyzed the sessions but found no recurring patterns worth reporting.";
738        let result = parse_analysis_response(text);
739        assert!(result.is_err());
740    }
741
742    #[test]
743    fn test_analysis_response_schema_is_valid_json() {
744        let value: serde_json::Value = serde_json::from_str(ANALYSIS_RESPONSE_SCHEMA)
745            .expect("ANALYSIS_RESPONSE_SCHEMA must be valid JSON");
746        assert_eq!(value["type"], "object");
747        assert!(value["properties"]["patterns"].is_object());
748    }
749
750    #[test]
751    fn test_full_management_analysis_schema_is_valid_json() {
752        let schema_str = full_management_analysis_schema();
753        let value: serde_json::Value =
754            serde_json::from_str(&schema_str).expect("full_management schema must be valid JSON");
755        assert_eq!(value["type"], "object");
756        assert!(value["properties"]["patterns"].is_object());
757    }
758
759    #[test]
760    fn test_full_management_analysis_schema_contains_claude_md_edits() {
761        let schema_str = full_management_analysis_schema();
762        let value: serde_json::Value = serde_json::from_str(&schema_str).unwrap();
763
764        // claude_md_edits should be in properties
765        let edits = &value["properties"]["claude_md_edits"];
766        assert!(edits.is_object(), "claude_md_edits should be in properties");
767        assert_eq!(edits["type"], "array");
768
769        // Items should have edit_type, reasoning as required
770        let items = &edits["items"];
771        assert_eq!(items["type"], "object");
772        let required: Vec<String> = items["required"]
773            .as_array()
774            .unwrap()
775            .iter()
776            .map(|v| v.as_str().unwrap().to_string())
777            .collect();
778        assert!(required.contains(&"edit_type".to_string()));
779        assert!(required.contains(&"reasoning".to_string()));
780
781        // edit_type should have the right enum values
782        let edit_type_enum = items["properties"]["edit_type"]["enum"]
783            .as_array()
784            .unwrap();
785        let enum_values: Vec<&str> = edit_type_enum.iter().map(|v| v.as_str().unwrap()).collect();
786        assert!(enum_values.contains(&"add"));
787        assert!(enum_values.contains(&"remove"));
788        assert!(enum_values.contains(&"reword"));
789        assert!(enum_values.contains(&"move"));
790
791        // additionalProperties should be false on items
792        assert_eq!(items["additionalProperties"], false);
793    }
794
795    #[test]
796    fn test_full_management_schema_claude_md_edits_not_required() {
797        let schema_str = full_management_analysis_schema();
798        let value: serde_json::Value = serde_json::from_str(&schema_str).unwrap();
799
800        // claude_md_edits should NOT be in the top-level required array
801        let required: Vec<String> = value["required"]
802            .as_array()
803            .unwrap()
804            .iter()
805            .map(|v| v.as_str().unwrap().to_string())
806            .collect();
807        assert!(
808            !required.contains(&"claude_md_edits".to_string()),
809            "claude_md_edits should NOT be in top-level required"
810        );
811        // But reasoning and patterns should still be required
812        assert!(required.contains(&"reasoning".to_string()));
813        assert!(required.contains(&"patterns".to_string()));
814    }
815
816    #[test]
817    fn test_full_management_schema_preserves_base_patterns() {
818        // The full_management schema should have the same patterns structure as the base schema
819        let base: serde_json::Value = serde_json::from_str(ANALYSIS_RESPONSE_SCHEMA).unwrap();
820        let full: serde_json::Value =
821            serde_json::from_str(&full_management_analysis_schema()).unwrap();
822
823        assert_eq!(
824            base["properties"]["patterns"],
825            full["properties"]["patterns"],
826            "patterns schema should be identical between base and full_management"
827        );
828        assert_eq!(
829            base["properties"]["reasoning"],
830            full["properties"]["reasoning"],
831            "reasoning schema should be identical"
832        );
833    }
834
835    #[test]
836    fn test_graph_analysis_schema_is_valid_json() {
837        let _: serde_json::Value = serde_json::from_str(GRAPH_ANALYSIS_RESPONSE_SCHEMA)
838            .expect("schema must be valid JSON");
839    }
840
841    #[test]
842    fn test_parse_graph_response() {
843        let json = r#"{
844            "reasoning": "Found testing pattern",
845            "operations": [
846                {
847                    "action": "create_node",
848                    "node_type": "rule",
849                    "scope": "project",
850                    "content": "Always run tests",
851                    "confidence": 0.85
852                },
853                {
854                    "action": "update_node",
855                    "node_id": "existing-1",
856                    "new_confidence": 0.9
857                }
858            ]
859        }"#;
860        let ops = parse_graph_response(json, Some("my-app")).unwrap();
861        assert_eq!(ops.len(), 2);
862        match &ops[0] {
863            GraphOperation::CreateNode { content, scope, .. } => {
864                assert_eq!(content, "Always run tests");
865                assert_eq!(*scope, NodeScope::Project);
866            }
867            _ => panic!("Expected CreateNode"),
868        }
869        match &ops[1] {
870            GraphOperation::UpdateNode { id, confidence, .. } => {
871                assert_eq!(id, "existing-1");
872                assert_eq!(*confidence, Some(0.9));
873            }
874            _ => panic!("Expected UpdateNode"),
875        }
876    }
877}