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
25pub 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
58pub 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
92pub 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
119pub 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 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 ClaudeCliBackend::check_auth()?;
143
144 let since = Utc::now() - Duration::days(window_days as i64);
145
146 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 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 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 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 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 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 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 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 let existing = db::get_patterns(conn, &["discovered", "active"], project)?;
248
249 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 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 let response = backend.execute(&prompt, Some(schema))?;
267 total_input_tokens += response.input_tokens;
268 total_output_tokens += response.output_tokens;
269
270 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 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 for pattern in &new_patterns {
292 db::insert_pattern(conn, pattern)?;
293 new_count += 1;
294 }
295
296 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 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 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 for ingested in &sessions_to_analyze {
367 db::record_analyzed_session(conn, &ingested.session_id, &ingested.project)?;
368 }
369
370 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
385pub 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 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 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 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 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 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 let compact_sessions: Vec<_> = parsed_sessions
489 .iter()
490 .map(prompts::to_compact_session)
491 .collect();
492
493 let existing_nodes = db::get_nodes_by_status(conn, &NodeStatus::Active).unwrap_or_default();
495
496 let project_slug = project.map(db::generate_project_slug);
498
499 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 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 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 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 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 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 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
569fn 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
582pub 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 _ => {} }
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 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 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 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 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 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 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 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}