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>(
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 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 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 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 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 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 let compact_sessions: Vec<_> = parsed_sessions
487 .iter()
488 .map(prompts::to_compact_session)
489 .collect();
490
491 let existing_nodes = db::get_nodes_by_status(conn, &NodeStatus::Active).unwrap_or_default();
493
494 let project_slug = project.map(db::generate_project_slug);
496
497 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 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 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 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 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 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 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
563fn 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
576pub 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 _ => {} }
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 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 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 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 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 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 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 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}