Skip to main content

autom8/claude/
utils.rs

1//! Utility functions for Claude operations.
2//!
3//! Provides helper functions for JSON fixing, context building, and output parsing.
4
5use crate::knowledge::ProjectKnowledge;
6use crate::state::IterationRecord;
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10const WORK_SUMMARY_START: &str = "<work-summary>";
11const WORK_SUMMARY_END: &str = "</work-summary>";
12const FILES_CONTEXT_START: &str = "<files-context>";
13const FILES_CONTEXT_END: &str = "</files-context>";
14const DECISIONS_START: &str = "<decisions>";
15const DECISIONS_END: &str = "</decisions>";
16const PATTERNS_START: &str = "<patterns>";
17const PATTERNS_END: &str = "</patterns>";
18
19/// A file context entry extracted from agent output.
20/// Contains semantic information about a file the agent worked with.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22#[serde(rename_all = "camelCase")]
23pub struct FileContextEntry {
24    /// Path to the file
25    pub path: PathBuf,
26    /// Brief description of the file's purpose
27    pub purpose: String,
28    /// Key symbols (functions, types, constants) in this file
29    pub key_symbols: Vec<String>,
30}
31
32/// A decision extracted from agent output.
33/// Represents an architectural or implementation choice made by the agent.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35#[serde(rename_all = "camelCase")]
36pub struct Decision {
37    /// The topic or area this decision relates to
38    pub topic: String,
39    /// The choice that was made
40    pub choice: String,
41    /// Why this choice was made
42    pub rationale: String,
43}
44
45/// A pattern extracted from agent output.
46/// Represents a coding pattern or convention established by the agent.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48#[serde(rename_all = "camelCase")]
49pub struct Pattern {
50    /// Description of the pattern
51    pub description: String,
52}
53const MAX_WORK_SUMMARY_LENGTH: usize = 500;
54
55/// Extract work summary from Claude's output using <work-summary>...</work-summary> markers.
56/// Returns None if no valid summary is found, for graceful degradation.
57/// Truncates to MAX_WORK_SUMMARY_LENGTH chars to prevent prompt bloat.
58pub fn extract_work_summary(output: &str) -> Option<String> {
59    let start_idx = output.find(WORK_SUMMARY_START)?;
60    let content_start = start_idx + WORK_SUMMARY_START.len();
61    let end_idx = output[content_start..].find(WORK_SUMMARY_END)?;
62
63    let summary = output[content_start..content_start + end_idx].trim();
64
65    if summary.is_empty() {
66        return None;
67    }
68
69    // Truncate to max length to prevent prompt bloat
70    let truncated = if summary.len() > MAX_WORK_SUMMARY_LENGTH {
71        let mut end = MAX_WORK_SUMMARY_LENGTH;
72        // Try to truncate at a word boundary
73        if let Some(last_space) = summary[..end].rfind(' ') {
74            end = last_space;
75        }
76        format!("{}...", &summary[..end])
77    } else {
78        summary.to_string()
79    };
80
81    Some(truncated)
82}
83
84/// Extract files context from Claude's output using `<files-context>...</files-context>` markers.
85/// Returns empty Vec if no valid context is found (graceful degradation).
86///
87/// Expected format inside tags (one entry per line):
88/// `path | purpose | [symbol1, symbol2]`
89///
90/// Example:
91/// ```text
92/// <files-context>
93/// src/main.rs | Application entry point | [main, run]
94/// src/lib.rs | Library exports | []
95/// </files-context>
96/// ```
97pub fn extract_files_context(output: &str) -> Vec<FileContextEntry> {
98    let Some(start_idx) = output.find(FILES_CONTEXT_START) else {
99        return Vec::new();
100    };
101    let content_start = start_idx + FILES_CONTEXT_START.len();
102    let Some(end_idx) = output[content_start..].find(FILES_CONTEXT_END) else {
103        return Vec::new();
104    };
105
106    let content = output[content_start..content_start + end_idx].trim();
107    if content.is_empty() {
108        return Vec::new();
109    }
110
111    content
112        .lines()
113        .filter_map(|line| {
114            let line = line.trim();
115            if line.is_empty() {
116                return None;
117            }
118
119            let parts: Vec<&str> = line.splitn(3, '|').collect();
120            if parts.len() < 2 {
121                return None;
122            }
123
124            let path = PathBuf::from(parts[0].trim());
125            let purpose = parts[1].trim().to_string();
126            let key_symbols = if parts.len() >= 3 {
127                parse_symbol_list(parts[2].trim())
128            } else {
129                Vec::new()
130            };
131
132            Some(FileContextEntry {
133                path,
134                purpose,
135                key_symbols,
136            })
137        })
138        .collect()
139}
140
141/// Extract decisions from Claude's output using `<decisions>...</decisions>` markers.
142/// Returns empty Vec if no valid decisions are found (graceful degradation).
143///
144/// Expected format inside tags (one entry per line):
145/// `topic | choice | rationale`
146///
147/// Example:
148/// ```text
149/// <decisions>
150/// Error handling | thiserror crate | Provides clean derive macros
151/// Database | SQLite | Embedded, no setup required
152/// </decisions>
153/// ```
154pub fn extract_decisions(output: &str) -> Vec<Decision> {
155    let Some(start_idx) = output.find(DECISIONS_START) else {
156        return Vec::new();
157    };
158    let content_start = start_idx + DECISIONS_START.len();
159    let Some(end_idx) = output[content_start..].find(DECISIONS_END) else {
160        return Vec::new();
161    };
162
163    let content = output[content_start..content_start + end_idx].trim();
164    if content.is_empty() {
165        return Vec::new();
166    }
167
168    content
169        .lines()
170        .filter_map(|line| {
171            let line = line.trim();
172            if line.is_empty() {
173                return None;
174            }
175
176            let parts: Vec<&str> = line.splitn(3, '|').collect();
177            if parts.len() < 3 {
178                return None;
179            }
180
181            Some(Decision {
182                topic: parts[0].trim().to_string(),
183                choice: parts[1].trim().to_string(),
184                rationale: parts[2].trim().to_string(),
185            })
186        })
187        .collect()
188}
189
190/// Extract patterns from Claude's output using `<patterns>...</patterns>` markers.
191/// Returns empty Vec if no valid patterns are found (graceful degradation).
192///
193/// Expected format inside tags (one pattern description per line):
194///
195/// Example:
196/// ```text
197/// <patterns>
198/// Use Result<T, Error> for all fallible operations
199/// Prefer explicit error types over Box<dyn Error>
200/// </patterns>
201/// ```
202pub fn extract_patterns(output: &str) -> Vec<Pattern> {
203    let Some(start_idx) = output.find(PATTERNS_START) else {
204        return Vec::new();
205    };
206    let content_start = start_idx + PATTERNS_START.len();
207    let Some(end_idx) = output[content_start..].find(PATTERNS_END) else {
208        return Vec::new();
209    };
210
211    let content = output[content_start..content_start + end_idx].trim();
212    if content.is_empty() {
213        return Vec::new();
214    }
215
216    content
217        .lines()
218        .filter_map(|line| {
219            let line = line.trim();
220            if line.is_empty() {
221                return None;
222            }
223
224            Some(Pattern {
225                description: line.to_string(),
226            })
227        })
228        .collect()
229}
230
231/// Parse a symbol list in the format `[symbol1, symbol2]` or `[]`.
232fn parse_symbol_list(input: &str) -> Vec<String> {
233    let trimmed = input.trim();
234
235    // Handle empty brackets or missing brackets
236    if trimmed.is_empty() || trimmed == "[]" {
237        return Vec::new();
238    }
239
240    // Strip brackets if present
241    let inner = if trimmed.starts_with('[') && trimmed.ends_with(']') {
242        &trimmed[1..trimmed.len() - 1]
243    } else {
244        trimmed
245    };
246
247    if inner.is_empty() {
248        return Vec::new();
249    }
250
251    inner
252        .split(',')
253        .map(|s| s.trim().to_string())
254        .filter(|s| !s.is_empty())
255        .collect()
256}
257
258/// Build a context string from previous iteration work summaries.
259/// Returns None if there are no previous iterations with summaries.
260/// Format: "US-001: [summary]\nUS-002: [summary]"
261pub fn build_previous_context(iterations: &[IterationRecord]) -> Option<String> {
262    let summaries: Vec<String> = iterations
263        .iter()
264        .filter_map(|iter| {
265            iter.work_summary
266                .as_ref()
267                .map(|summary| format!("{}: {}", iter.story_id, summary))
268        })
269        .collect();
270
271    if summaries.is_empty() {
272        None
273    } else {
274        Some(summaries.join("\n"))
275    }
276}
277
278/// Build a knowledge context string for injection into agent prompts.
279/// Returns None if knowledge is empty (no files, no decisions, no patterns, no story_changes).
280///
281/// The output includes:
282/// - Files Modified in This Run (table format: path, purpose, key symbols, stories)
283/// - Architectural Decisions (topic, choice, rationale)
284/// - Patterns to Follow (list of patterns)
285/// - Recent Work (summary of completed story changes)
286///
287/// Formatting is kept concise to stay under ~1500 tokens.
288pub fn build_knowledge_context(knowledge: &ProjectKnowledge) -> Option<String> {
289    // Check if knowledge is empty
290    if knowledge.files.is_empty()
291        && knowledge.decisions.is_empty()
292        && knowledge.patterns.is_empty()
293        && knowledge.story_changes.is_empty()
294    {
295        return None;
296    }
297
298    let mut sections: Vec<String> = Vec::new();
299
300    // Files Modified section (as a table)
301    if !knowledge.files.is_empty() {
302        let mut files_section = String::from("## Files Modified in This Run\n\n");
303        files_section.push_str("| Path | Purpose | Key Symbols | Stories |\n");
304        files_section.push_str("|------|---------|-------------|--------|\n");
305
306        // Sort files by path for consistent output
307        let mut file_entries: Vec<_> = knowledge.files.iter().collect();
308        file_entries.sort_by(|a, b| a.0.cmp(b.0));
309
310        for (path, info) in file_entries {
311            let path_str = abbreviate_path(path.to_string_lossy().as_ref());
312            let purpose = truncate_str(&info.purpose, 40);
313            let symbols = if info.key_symbols.is_empty() {
314                "-".to_string()
315            } else {
316                truncate_str(&info.key_symbols.join(", "), 30)
317            };
318            let stories = if info.touched_by.is_empty() {
319                "-".to_string()
320            } else {
321                info.touched_by.join(", ")
322            };
323
324            files_section.push_str(&format!(
325                "| {} | {} | {} | {} |\n",
326                path_str, purpose, symbols, stories
327            ));
328        }
329
330        sections.push(files_section);
331    }
332
333    // Architectural Decisions section
334    if !knowledge.decisions.is_empty() {
335        let mut decisions_section = String::from("## Architectural Decisions\n\n");
336
337        for decision in &knowledge.decisions {
338            decisions_section.push_str(&format!(
339                "- **{}**: {} — {}\n",
340                decision.topic,
341                decision.choice,
342                truncate_str(&decision.rationale, 60)
343            ));
344        }
345
346        sections.push(decisions_section);
347    }
348
349    // Patterns to Follow section
350    if !knowledge.patterns.is_empty() {
351        let mut patterns_section = String::from("## Patterns to Follow\n\n");
352
353        for pattern in &knowledge.patterns {
354            let example = pattern
355                .example_file
356                .as_ref()
357                .map(|p| format!(" (see {})", abbreviate_path(p.to_string_lossy().as_ref())))
358                .unwrap_or_default();
359            patterns_section.push_str(&format!("- {}{}\n", pattern.description, example));
360        }
361
362        sections.push(patterns_section);
363    }
364
365    // Recent Work section (story changes summary)
366    if !knowledge.story_changes.is_empty() {
367        let mut work_section = String::from("## Recent Work\n\n");
368
369        for story in &knowledge.story_changes {
370            let mut file_list: Vec<String> = Vec::new();
371
372            for fc in &story.files_created {
373                file_list.push(format!(
374                    "+{}",
375                    abbreviate_path(fc.path.to_string_lossy().as_ref())
376                ));
377            }
378            for fc in &story.files_modified {
379                file_list.push(format!(
380                    "~{}",
381                    abbreviate_path(fc.path.to_string_lossy().as_ref())
382                ));
383            }
384            for path in &story.files_deleted {
385                file_list.push(format!(
386                    "-{}",
387                    abbreviate_path(path.to_string_lossy().as_ref())
388                ));
389            }
390
391            let files_str = if file_list.is_empty() {
392                "no file changes".to_string()
393            } else {
394                truncate_str(&file_list.join(", "), 80)
395            };
396
397            work_section.push_str(&format!("- **{}**: {}\n", story.story_id, files_str));
398        }
399
400        sections.push(work_section);
401    }
402
403    if sections.is_empty() {
404        None
405    } else {
406        Some(sections.join("\n"))
407    }
408}
409
410/// Abbreviate a file path for concise display.
411/// Converts "src/claude/utils.rs" to "s/claude/utils.rs" for paths starting with "src/".
412fn abbreviate_path(path: &str) -> String {
413    if let Some(stripped) = path.strip_prefix("src/") {
414        format!("s/{}", stripped)
415    } else {
416        path.to_string()
417    }
418}
419
420/// Truncate a string to max_len characters, adding "..." if truncated.
421fn truncate_str(s: &str, max_len: usize) -> String {
422    if s.len() <= max_len {
423        s.to_string()
424    } else {
425        format!("{}...", &s[..max_len.saturating_sub(3)])
426    }
427}
428
429/// Fix common JSON syntax errors without calling Claude.
430/// This is a conservative fixer that only corrects unambiguous errors:
431/// - Strips markdown code fences (```json ... ``` and ``` ... ```)
432/// - Removes trailing commas before ] and }
433/// - Quotes unquoted keys that match identifier patterns
434///
435/// The function is idempotent - running it twice produces the same output.
436pub fn fix_json_syntax(input: &str) -> String {
437    use regex::Regex;
438
439    let mut result = input.to_string();
440
441    // Step 1: Strip markdown code fences
442    let code_fence_re = Regex::new(r"(?s)^```(?:json)?\s*\n?(.*?)\n?```\s*$").unwrap();
443    if let Some(captures) = code_fence_re.captures(&result) {
444        if let Some(content) = captures.get(1) {
445            result = content.as_str().to_string();
446        }
447    }
448
449    // Also handle code fences that aren't at the start/end but wrap the entire JSON
450    let inline_fence_re = Regex::new(r"(?s)```(?:json)?\s*\n(.*?)\n```").unwrap();
451    if let Some(captures) = inline_fence_re.captures(&result) {
452        if let Some(content) = captures.get(1) {
453            result = content.as_str().to_string();
454        }
455    }
456
457    // Step 2: Quote unquoted keys that match identifier patterns
458    let unquoted_key_re = Regex::new(r#"([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)"#).unwrap();
459    result = unquoted_key_re
460        .replace_all(&result, |caps: &regex::Captures| {
461            format!(
462                "{}\"{}\"{}",
463                caps.get(1).map_or("", |m| m.as_str()),
464                caps.get(2).map_or("", |m| m.as_str()),
465                caps.get(3).map_or("", |m| m.as_str())
466            )
467        })
468        .to_string();
469
470    // Step 3: Remove trailing commas before ] and }
471    let trailing_comma_re = Regex::new(r",(\s*[}\]])").unwrap();
472    result = trailing_comma_re.replace_all(&result, "$1").to_string();
473
474    result.trim().to_string()
475}
476
477/// Extract JSON from Claude's response, handling potential markdown code blocks
478pub fn extract_json(response: &str) -> Option<String> {
479    let trimmed = response.trim();
480
481    // Try to find JSON in markdown code block
482    if let Some(start) = trimmed.find("```json") {
483        let content_start = start + 7;
484        if let Some(end) = trimmed[content_start..].find("```") {
485            return Some(
486                trimmed[content_start..content_start + end]
487                    .trim()
488                    .to_string(),
489            );
490        }
491    }
492
493    // Try to find JSON in generic code block
494    if let Some(start) = trimmed.find("```") {
495        let content_start = start + 3;
496        let content_start = trimmed[content_start..]
497            .find('\n')
498            .map(|i| content_start + i + 1)
499            .unwrap_or(content_start);
500        if let Some(end) = trimmed[content_start..].find("```") {
501            return Some(
502                trimmed[content_start..content_start + end]
503                    .trim()
504                    .to_string(),
505            );
506        }
507    }
508
509    // Try to find raw JSON object
510    if let Some(start) = trimmed.find('{') {
511        if let Some(end) = trimmed.rfind('}') {
512            if end > start {
513                return Some(trimmed[start..=end].to_string());
514            }
515        }
516    }
517
518    None
519}
520
521/// Truncate JSON string for error preview, preserving readability.
522pub fn truncate_json_preview(json: &str, max_len: usize) -> String {
523    let trimmed = json.trim();
524    if trimmed.len() <= max_len {
525        trimmed.to_string()
526    } else {
527        format!("{}...", &trimmed[..max_len])
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn test_extract_work_summary_basic() {
537        let output = r#"I made some changes.
538
539<work-summary>
540Files changed: src/main.rs, src/lib.rs. Added new authentication module.
541</work-summary>
542
543Done!"#;
544        let summary = extract_work_summary(output);
545        assert!(summary.is_some());
546        assert!(summary.unwrap().contains("Files changed"));
547    }
548
549    #[test]
550    fn test_extract_work_summary_missing() {
551        let output = "No summary here";
552        let summary = extract_work_summary(output);
553        assert!(summary.is_none());
554    }
555
556    #[test]
557    fn test_extract_json_from_code_block() {
558        let response = r#"Here's the JSON:
559```json
560{"project": "Test"}
561```
562Done!"#;
563        let json = extract_json(response).unwrap();
564        assert_eq!(json, r#"{"project": "Test"}"#);
565    }
566
567    #[test]
568    fn test_extract_json_raw() {
569        let response = r#"{"project": "Test", "branchName": "main"}"#;
570        let json = extract_json(response).unwrap();
571        assert_eq!(json, r#"{"project": "Test", "branchName": "main"}"#);
572    }
573
574    // ===========================================
575    // extract_files_context tests
576    // ===========================================
577
578    #[test]
579    fn test_extract_files_context_basic() {
580        let output = r#"Here's what I did:
581
582<files-context>
583src/main.rs | Application entry point | [main, run]
584src/lib.rs | Library exports | [Config, Runner]
585</files-context>
586
587Done!"#;
588        let entries = extract_files_context(output);
589        assert_eq!(entries.len(), 2);
590
591        assert_eq!(entries[0].path, PathBuf::from("src/main.rs"));
592        assert_eq!(entries[0].purpose, "Application entry point");
593        assert_eq!(entries[0].key_symbols, vec!["main", "run"]);
594
595        assert_eq!(entries[1].path, PathBuf::from("src/lib.rs"));
596        assert_eq!(entries[1].purpose, "Library exports");
597        assert_eq!(entries[1].key_symbols, vec!["Config", "Runner"]);
598    }
599
600    #[test]
601    fn test_extract_files_context_empty_symbols() {
602        let output = r#"<files-context>
603src/lib.rs | Library exports | []
604</files-context>"#;
605        let entries = extract_files_context(output);
606        assert_eq!(entries.len(), 1);
607        assert!(entries[0].key_symbols.is_empty());
608    }
609
610    #[test]
611    fn test_extract_files_context_no_symbols_field() {
612        let output = r#"<files-context>
613src/main.rs | Application entry point
614</files-context>"#;
615        let entries = extract_files_context(output);
616        assert_eq!(entries.len(), 1);
617        assert_eq!(entries[0].path, PathBuf::from("src/main.rs"));
618        assert_eq!(entries[0].purpose, "Application entry point");
619        assert!(entries[0].key_symbols.is_empty());
620    }
621
622    #[test]
623    fn test_extract_files_context_missing_tags() {
624        let output = "No files context here";
625        let entries = extract_files_context(output);
626        assert!(entries.is_empty());
627    }
628
629    #[test]
630    fn test_extract_files_context_empty_content() {
631        let output = r#"<files-context>
632</files-context>"#;
633        let entries = extract_files_context(output);
634        assert!(entries.is_empty());
635    }
636
637    #[test]
638    fn test_extract_files_context_whitespace_only() {
639        let output = r#"<files-context>
640
641
642
643</files-context>"#;
644        let entries = extract_files_context(output);
645        assert!(entries.is_empty());
646    }
647
648    #[test]
649    fn test_extract_files_context_single_symbol() {
650        let output = r#"<files-context>
651src/config.rs | Configuration | [Config]
652</files-context>"#;
653        let entries = extract_files_context(output);
654        assert_eq!(entries.len(), 1);
655        assert_eq!(entries[0].key_symbols, vec!["Config"]);
656    }
657
658    #[test]
659    fn test_extract_files_context_unclosed_tag() {
660        let output = r#"<files-context>
661src/main.rs | Entry point | [main]
662"#;
663        let entries = extract_files_context(output);
664        assert!(entries.is_empty());
665    }
666
667    #[test]
668    fn test_extract_files_context_invalid_line() {
669        let output = r#"<files-context>
670src/main.rs | Entry point | [main]
671invalid line without pipes
672src/lib.rs | Library | [mod]
673</files-context>"#;
674        let entries = extract_files_context(output);
675        assert_eq!(entries.len(), 2);
676    }
677
678    // ===========================================
679    // extract_decisions tests
680    // ===========================================
681
682    #[test]
683    fn test_extract_decisions_basic() {
684        let output = r#"Here's what I decided:
685
686<decisions>
687Error handling | thiserror crate | Provides clean derive macros
688Database | SQLite | Embedded, no setup required
689</decisions>
690
691Done!"#;
692        let decisions = extract_decisions(output);
693        assert_eq!(decisions.len(), 2);
694
695        assert_eq!(decisions[0].topic, "Error handling");
696        assert_eq!(decisions[0].choice, "thiserror crate");
697        assert_eq!(decisions[0].rationale, "Provides clean derive macros");
698
699        assert_eq!(decisions[1].topic, "Database");
700        assert_eq!(decisions[1].choice, "SQLite");
701        assert_eq!(decisions[1].rationale, "Embedded, no setup required");
702    }
703
704    #[test]
705    fn test_extract_decisions_missing_tags() {
706        let output = "No decisions here";
707        let decisions = extract_decisions(output);
708        assert!(decisions.is_empty());
709    }
710
711    #[test]
712    fn test_extract_decisions_empty_content() {
713        let output = r#"<decisions>
714</decisions>"#;
715        let decisions = extract_decisions(output);
716        assert!(decisions.is_empty());
717    }
718
719    #[test]
720    fn test_extract_decisions_incomplete_line() {
721        let output = r#"<decisions>
722Error handling | thiserror
723Database | SQLite | Embedded
724</decisions>"#;
725        let decisions = extract_decisions(output);
726        // Only second line is valid (has all 3 parts)
727        assert_eq!(decisions.len(), 1);
728        assert_eq!(decisions[0].topic, "Database");
729    }
730
731    #[test]
732    fn test_extract_decisions_with_pipes_in_rationale() {
733        let output = r#"<decisions>
734Separator | Pipe char | Use | for separating values in output
735</decisions>"#;
736        let decisions = extract_decisions(output);
737        assert_eq!(decisions.len(), 1);
738        assert_eq!(decisions[0].topic, "Separator");
739        assert_eq!(decisions[0].choice, "Pipe char");
740        // splitn(3, '|') means rationale captures everything after 2nd pipe
741        assert_eq!(
742            decisions[0].rationale,
743            "Use | for separating values in output"
744        );
745    }
746
747    #[test]
748    fn test_extract_decisions_unclosed_tag() {
749        let output = r#"<decisions>
750Topic | Choice | Rationale
751"#;
752        let decisions = extract_decisions(output);
753        assert!(decisions.is_empty());
754    }
755
756    #[test]
757    fn test_extract_decisions_whitespace_handling() {
758        let output = r#"<decisions>
759   Topic   |   Choice   |   Rationale with spaces
760</decisions>"#;
761        let decisions = extract_decisions(output);
762        assert_eq!(decisions.len(), 1);
763        assert_eq!(decisions[0].topic, "Topic");
764        assert_eq!(decisions[0].choice, "Choice");
765        assert_eq!(decisions[0].rationale, "Rationale with spaces");
766    }
767
768    // ===========================================
769    // extract_patterns tests
770    // ===========================================
771
772    #[test]
773    fn test_extract_patterns_basic() {
774        let output = r#"Here are the patterns:
775
776<patterns>
777Use Result<T, Error> for all fallible operations
778Prefer explicit error types over Box<dyn Error>
779Use snake_case for function names
780</patterns>
781
782Done!"#;
783        let patterns = extract_patterns(output);
784        assert_eq!(patterns.len(), 3);
785
786        assert_eq!(
787            patterns[0].description,
788            "Use Result<T, Error> for all fallible operations"
789        );
790        assert_eq!(
791            patterns[1].description,
792            "Prefer explicit error types over Box<dyn Error>"
793        );
794        assert_eq!(patterns[2].description, "Use snake_case for function names");
795    }
796
797    #[test]
798    fn test_extract_patterns_missing_tags() {
799        let output = "No patterns here";
800        let patterns = extract_patterns(output);
801        assert!(patterns.is_empty());
802    }
803
804    #[test]
805    fn test_extract_patterns_empty_content() {
806        let output = r#"<patterns>
807</patterns>"#;
808        let patterns = extract_patterns(output);
809        assert!(patterns.is_empty());
810    }
811
812    #[test]
813    fn test_extract_patterns_with_blank_lines() {
814        let output = r#"<patterns>
815Pattern one
816
817Pattern two
818
819</patterns>"#;
820        let patterns = extract_patterns(output);
821        assert_eq!(patterns.len(), 2);
822        assert_eq!(patterns[0].description, "Pattern one");
823        assert_eq!(patterns[1].description, "Pattern two");
824    }
825
826    #[test]
827    fn test_extract_patterns_single_pattern() {
828        let output = r#"<patterns>
829Single pattern here
830</patterns>"#;
831        let patterns = extract_patterns(output);
832        assert_eq!(patterns.len(), 1);
833        assert_eq!(patterns[0].description, "Single pattern here");
834    }
835
836    #[test]
837    fn test_extract_patterns_unclosed_tag() {
838        let output = r#"<patterns>
839Pattern one
840"#;
841        let patterns = extract_patterns(output);
842        assert!(patterns.is_empty());
843    }
844
845    #[test]
846    fn test_extract_patterns_whitespace_trimmed() {
847        let output = r#"<patterns>
848   Pattern with leading/trailing spaces
849</patterns>"#;
850        let patterns = extract_patterns(output);
851        assert_eq!(patterns.len(), 1);
852        assert_eq!(
853            patterns[0].description,
854            "Pattern with leading/trailing spaces"
855        );
856    }
857
858    // ===========================================
859    // parse_symbol_list tests
860    // ===========================================
861
862    #[test]
863    fn test_parse_symbol_list_basic() {
864        let symbols = parse_symbol_list("[foo, bar, baz]");
865        assert_eq!(symbols, vec!["foo", "bar", "baz"]);
866    }
867
868    #[test]
869    fn test_parse_symbol_list_empty_brackets() {
870        let symbols = parse_symbol_list("[]");
871        assert!(symbols.is_empty());
872    }
873
874    #[test]
875    fn test_parse_symbol_list_empty_string() {
876        let symbols = parse_symbol_list("");
877        assert!(symbols.is_empty());
878    }
879
880    #[test]
881    fn test_parse_symbol_list_whitespace() {
882        let symbols = parse_symbol_list("  [  foo  ,  bar  ]  ");
883        assert_eq!(symbols, vec!["foo", "bar"]);
884    }
885
886    #[test]
887    fn test_parse_symbol_list_no_brackets() {
888        let symbols = parse_symbol_list("foo, bar");
889        assert_eq!(symbols, vec!["foo", "bar"]);
890    }
891
892    #[test]
893    fn test_parse_symbol_list_single_symbol() {
894        let symbols = parse_symbol_list("[Config]");
895        assert_eq!(symbols, vec!["Config"]);
896    }
897
898    // ===========================================
899    // Serialization tests for new types
900    // ===========================================
901
902    #[test]
903    fn test_file_context_entry_serialization() {
904        let entry = FileContextEntry {
905            path: PathBuf::from("src/main.rs"),
906            purpose: "Entry point".to_string(),
907            key_symbols: vec!["main".to_string()],
908        };
909
910        let json = serde_json::to_string(&entry).unwrap();
911        assert!(json.contains("keySymbols"));
912
913        let deserialized: FileContextEntry = serde_json::from_str(&json).unwrap();
914        assert_eq!(deserialized.path, PathBuf::from("src/main.rs"));
915        assert_eq!(deserialized.purpose, "Entry point");
916        assert_eq!(deserialized.key_symbols, vec!["main"]);
917    }
918
919    #[test]
920    fn test_decision_serialization() {
921        let decision = Decision {
922            topic: "DB".to_string(),
923            choice: "SQLite".to_string(),
924            rationale: "Simple".to_string(),
925        };
926
927        let json = serde_json::to_string(&decision).unwrap();
928        let deserialized: Decision = serde_json::from_str(&json).unwrap();
929
930        assert_eq!(deserialized.topic, "DB");
931        assert_eq!(deserialized.choice, "SQLite");
932        assert_eq!(deserialized.rationale, "Simple");
933    }
934
935    #[test]
936    fn test_pattern_serialization() {
937        let pattern = Pattern {
938            description: "Use Result for errors".to_string(),
939        };
940
941        let json = serde_json::to_string(&pattern).unwrap();
942        let deserialized: Pattern = serde_json::from_str(&json).unwrap();
943
944        assert_eq!(deserialized.description, "Use Result for errors");
945    }
946
947    // ===========================================
948    // Integration tests with combined output
949    // ===========================================
950
951    #[test]
952    fn test_extract_all_context_types() {
953        let output = r#"I've completed the implementation.
954
955<work-summary>
956Files changed: src/main.rs, src/lib.rs. Added authentication module.
957</work-summary>
958
959<files-context>
960src/main.rs | Application entry point | [main]
961src/auth.rs | Authentication logic | [authenticate, verify]
962</files-context>
963
964<decisions>
965Auth method | JWT | Stateless, scalable
966</decisions>
967
968<patterns>
969Use Result<T, AuthError> for auth operations
970</patterns>
971
972Done!"#;
973
974        let summary = extract_work_summary(output);
975        assert!(summary.is_some());
976        assert!(summary.unwrap().contains("authentication module"));
977
978        let files = extract_files_context(output);
979        assert_eq!(files.len(), 2);
980
981        let decisions = extract_decisions(output);
982        assert_eq!(decisions.len(), 1);
983        assert_eq!(decisions[0].topic, "Auth method");
984
985        let patterns = extract_patterns(output);
986        assert_eq!(patterns.len(), 1);
987        assert!(patterns[0].description.contains("AuthError"));
988    }
989
990    #[test]
991    fn test_extract_from_output_with_no_context() {
992        let output = "Just some regular output with no special tags";
993
994        let summary = extract_work_summary(output);
995        assert!(summary.is_none());
996
997        let files = extract_files_context(output);
998        assert!(files.is_empty());
999
1000        let decisions = extract_decisions(output);
1001        assert!(decisions.is_empty());
1002
1003        let patterns = extract_patterns(output);
1004        assert!(patterns.is_empty());
1005    }
1006
1007    // ===========================================
1008    // build_knowledge_context tests
1009    // ===========================================
1010
1011    use crate::knowledge::{
1012        Decision as KnowledgeDecision, FileChange, FileInfo, Pattern as KnowledgePattern,
1013        ProjectKnowledge, StoryChanges,
1014    };
1015
1016    #[test]
1017    fn test_build_knowledge_context_empty_returns_none() {
1018        let knowledge = ProjectKnowledge::default();
1019        let result = build_knowledge_context(&knowledge);
1020        assert!(result.is_none());
1021    }
1022
1023    #[test]
1024    fn test_build_knowledge_context_with_files_only() {
1025        let mut knowledge = ProjectKnowledge::default();
1026        knowledge.files.insert(
1027            PathBuf::from("src/main.rs"),
1028            FileInfo {
1029                purpose: "Application entry point".to_string(),
1030                key_symbols: vec!["main".to_string(), "run".to_string()],
1031                touched_by: vec!["US-001".to_string()],
1032                line_count: 100,
1033            },
1034        );
1035
1036        let result = build_knowledge_context(&knowledge);
1037        assert!(result.is_some());
1038        let context = result.unwrap();
1039
1040        assert!(context.contains("## Files Modified in This Run"));
1041        assert!(context.contains("| Path | Purpose | Key Symbols | Stories |"));
1042        assert!(context.contains("s/main.rs"));
1043        assert!(context.contains("Application entry point"));
1044        assert!(context.contains("main, run"));
1045        assert!(context.contains("US-001"));
1046    }
1047
1048    #[test]
1049    fn test_build_knowledge_context_with_decisions_only() {
1050        let mut knowledge = ProjectKnowledge::default();
1051        knowledge.decisions.push(KnowledgeDecision {
1052            story_id: "US-001".to_string(),
1053            topic: "Error handling".to_string(),
1054            choice: "thiserror crate".to_string(),
1055            rationale: "Provides clean derive macros for error types".to_string(),
1056        });
1057
1058        let result = build_knowledge_context(&knowledge);
1059        assert!(result.is_some());
1060        let context = result.unwrap();
1061
1062        assert!(context.contains("## Architectural Decisions"));
1063        assert!(context.contains("**Error handling**"));
1064        assert!(context.contains("thiserror crate"));
1065        assert!(context.contains("Provides clean derive macros"));
1066    }
1067
1068    #[test]
1069    fn test_build_knowledge_context_with_patterns_only() {
1070        let mut knowledge = ProjectKnowledge::default();
1071        knowledge.patterns.push(KnowledgePattern {
1072            story_id: "US-001".to_string(),
1073            description: "Use Result<T, Error> for all fallible operations".to_string(),
1074            example_file: Some(PathBuf::from("src/runner.rs")),
1075        });
1076
1077        let result = build_knowledge_context(&knowledge);
1078        assert!(result.is_some());
1079        let context = result.unwrap();
1080
1081        assert!(context.contains("## Patterns to Follow"));
1082        assert!(context.contains("Use Result<T, Error>"));
1083        assert!(context.contains("(see s/runner.rs)"));
1084    }
1085
1086    #[test]
1087    fn test_build_knowledge_context_with_patterns_no_example() {
1088        let mut knowledge = ProjectKnowledge::default();
1089        knowledge.patterns.push(KnowledgePattern {
1090            story_id: "US-001".to_string(),
1091            description: "Use snake_case for function names".to_string(),
1092            example_file: None,
1093        });
1094
1095        let result = build_knowledge_context(&knowledge);
1096        assert!(result.is_some());
1097        let context = result.unwrap();
1098
1099        assert!(context.contains("Use snake_case for function names"));
1100        assert!(!context.contains("(see"));
1101    }
1102
1103    #[test]
1104    fn test_build_knowledge_context_with_story_changes_only() {
1105        let mut knowledge = ProjectKnowledge::default();
1106        knowledge.story_changes.push(StoryChanges {
1107            story_id: "US-001".to_string(),
1108            files_created: vec![FileChange {
1109                path: PathBuf::from("src/knowledge.rs"),
1110                additions: 200,
1111                deletions: 0,
1112                purpose: Some("Knowledge tracking".to_string()),
1113                key_symbols: vec!["ProjectKnowledge".to_string()],
1114            }],
1115            files_modified: vec![FileChange {
1116                path: PathBuf::from("src/lib.rs"),
1117                additions: 1,
1118                deletions: 0,
1119                purpose: None,
1120                key_symbols: vec![],
1121            }],
1122            files_deleted: vec![PathBuf::from("src/old.rs")],
1123            commit_hash: Some("abc123".to_string()),
1124        });
1125
1126        let result = build_knowledge_context(&knowledge);
1127        assert!(result.is_some());
1128        let context = result.unwrap();
1129
1130        assert!(context.contains("## Recent Work"));
1131        assert!(context.contains("**US-001**"));
1132        assert!(context.contains("+s/knowledge.rs")); // + for created
1133        assert!(context.contains("~s/lib.rs")); // ~ for modified
1134        assert!(context.contains("-s/old.rs")); // - for deleted
1135    }
1136
1137    #[test]
1138    fn test_build_knowledge_context_story_changes_no_files() {
1139        let mut knowledge = ProjectKnowledge::default();
1140        knowledge.story_changes.push(StoryChanges {
1141            story_id: "US-001".to_string(),
1142            files_created: vec![],
1143            files_modified: vec![],
1144            files_deleted: vec![],
1145            commit_hash: None,
1146        });
1147
1148        let result = build_knowledge_context(&knowledge);
1149        assert!(result.is_some());
1150        let context = result.unwrap();
1151
1152        assert!(context.contains("no file changes"));
1153    }
1154
1155    #[test]
1156    fn test_build_knowledge_context_full_knowledge() {
1157        let mut knowledge = ProjectKnowledge::default();
1158
1159        // Add files
1160        knowledge.files.insert(
1161            PathBuf::from("src/main.rs"),
1162            FileInfo {
1163                purpose: "Entry point".to_string(),
1164                key_symbols: vec!["main".to_string()],
1165                touched_by: vec!["US-001".to_string()],
1166                line_count: 50,
1167            },
1168        );
1169
1170        // Add decisions
1171        knowledge.decisions.push(KnowledgeDecision {
1172            story_id: "US-001".to_string(),
1173            topic: "Database".to_string(),
1174            choice: "SQLite".to_string(),
1175            rationale: "Embedded, no setup required".to_string(),
1176        });
1177
1178        // Add patterns
1179        knowledge.patterns.push(KnowledgePattern {
1180            story_id: "US-001".to_string(),
1181            description: "Use Result for errors".to_string(),
1182            example_file: None,
1183        });
1184
1185        // Add story changes
1186        knowledge.story_changes.push(StoryChanges {
1187            story_id: "US-001".to_string(),
1188            files_created: vec![FileChange {
1189                path: PathBuf::from("src/db.rs"),
1190                additions: 100,
1191                deletions: 0,
1192                purpose: None,
1193                key_symbols: vec![],
1194            }],
1195            files_modified: vec![],
1196            files_deleted: vec![],
1197            commit_hash: None,
1198        });
1199
1200        let result = build_knowledge_context(&knowledge);
1201        assert!(result.is_some());
1202        let context = result.unwrap();
1203
1204        // Verify all sections present
1205        assert!(context.contains("## Files Modified in This Run"));
1206        assert!(context.contains("## Architectural Decisions"));
1207        assert!(context.contains("## Patterns to Follow"));
1208        assert!(context.contains("## Recent Work"));
1209    }
1210
1211    #[test]
1212    fn test_build_knowledge_context_files_sorted_by_path() {
1213        let mut knowledge = ProjectKnowledge::default();
1214
1215        knowledge.files.insert(
1216            PathBuf::from("src/z_module.rs"),
1217            FileInfo {
1218                purpose: "Z module".to_string(),
1219                key_symbols: vec![],
1220                touched_by: vec![],
1221                line_count: 10,
1222            },
1223        );
1224        knowledge.files.insert(
1225            PathBuf::from("src/a_module.rs"),
1226            FileInfo {
1227                purpose: "A module".to_string(),
1228                key_symbols: vec![],
1229                touched_by: vec![],
1230                line_count: 10,
1231            },
1232        );
1233
1234        let result = build_knowledge_context(&knowledge).unwrap();
1235
1236        // A should come before Z
1237        let a_pos = result.find("a_module.rs").unwrap();
1238        let z_pos = result.find("z_module.rs").unwrap();
1239        assert!(a_pos < z_pos);
1240    }
1241
1242    #[test]
1243    fn test_build_knowledge_context_truncates_long_purpose() {
1244        let mut knowledge = ProjectKnowledge::default();
1245
1246        knowledge.files.insert(
1247            PathBuf::from("src/main.rs"),
1248            FileInfo {
1249                purpose: "This is a very long purpose description that should be truncated to fit in the table cell properly".to_string(),
1250                key_symbols: vec![],
1251                touched_by: vec![],
1252                line_count: 10,
1253            },
1254        );
1255
1256        let result = build_knowledge_context(&knowledge).unwrap();
1257
1258        // Should be truncated with ...
1259        assert!(result.contains("..."));
1260        // Should not contain the full string
1261        assert!(!result.contains("properly"));
1262    }
1263
1264    #[test]
1265    fn test_build_knowledge_context_truncates_long_rationale() {
1266        let mut knowledge = ProjectKnowledge::default();
1267
1268        knowledge.decisions.push(KnowledgeDecision {
1269            story_id: "US-001".to_string(),
1270            topic: "Test".to_string(),
1271            choice: "Option A".to_string(),
1272            rationale: "This is a very long rationale that explains in great detail why we made this particular choice and all the considerations involved".to_string(),
1273        });
1274
1275        let result = build_knowledge_context(&knowledge).unwrap();
1276
1277        // Should be truncated
1278        assert!(result.contains("..."));
1279    }
1280
1281    #[test]
1282    fn test_build_knowledge_context_files_with_empty_symbols() {
1283        let mut knowledge = ProjectKnowledge::default();
1284
1285        knowledge.files.insert(
1286            PathBuf::from("src/main.rs"),
1287            FileInfo {
1288                purpose: "Entry point".to_string(),
1289                key_symbols: vec![],
1290                touched_by: vec!["US-001".to_string()],
1291                line_count: 10,
1292            },
1293        );
1294
1295        let result = build_knowledge_context(&knowledge).unwrap();
1296
1297        // Empty symbols should show as "-"
1298        assert!(result.contains("| - |"));
1299    }
1300
1301    #[test]
1302    fn test_build_knowledge_context_files_with_empty_touched_by() {
1303        let mut knowledge = ProjectKnowledge::default();
1304
1305        knowledge.files.insert(
1306            PathBuf::from("src/main.rs"),
1307            FileInfo {
1308                purpose: "Entry point".to_string(),
1309                key_symbols: vec!["main".to_string()],
1310                touched_by: vec![],
1311                line_count: 10,
1312            },
1313        );
1314
1315        let result = build_knowledge_context(&knowledge).unwrap();
1316
1317        // Empty touched_by should show as "-"
1318        // The pattern "| - |" might appear for symbols, so check end of line
1319        assert!(result.contains("| - |\n"));
1320    }
1321
1322    #[test]
1323    fn test_abbreviate_path_src_prefix() {
1324        assert_eq!(abbreviate_path("src/main.rs"), "s/main.rs");
1325        assert_eq!(abbreviate_path("src/claude/utils.rs"), "s/claude/utils.rs");
1326    }
1327
1328    #[test]
1329    fn test_abbreviate_path_no_src_prefix() {
1330        assert_eq!(abbreviate_path("tests/main.rs"), "tests/main.rs");
1331        assert_eq!(abbreviate_path("Cargo.toml"), "Cargo.toml");
1332    }
1333
1334    #[test]
1335    fn test_truncate_str_short() {
1336        assert_eq!(truncate_str("hello", 10), "hello");
1337    }
1338
1339    #[test]
1340    fn test_truncate_str_exact() {
1341        assert_eq!(truncate_str("hello", 5), "hello");
1342    }
1343
1344    #[test]
1345    fn test_truncate_str_long() {
1346        assert_eq!(truncate_str("hello world", 8), "hello...");
1347    }
1348
1349    #[test]
1350    fn test_build_knowledge_context_multiple_stories() {
1351        let mut knowledge = ProjectKnowledge::default();
1352
1353        knowledge.story_changes.push(StoryChanges {
1354            story_id: "US-001".to_string(),
1355            files_created: vec![FileChange {
1356                path: PathBuf::from("src/first.rs"),
1357                additions: 50,
1358                deletions: 0,
1359                purpose: None,
1360                key_symbols: vec![],
1361            }],
1362            files_modified: vec![],
1363            files_deleted: vec![],
1364            commit_hash: None,
1365        });
1366
1367        knowledge.story_changes.push(StoryChanges {
1368            story_id: "US-002".to_string(),
1369            files_modified: vec![FileChange {
1370                path: PathBuf::from("src/second.rs"),
1371                additions: 10,
1372                deletions: 5,
1373                purpose: None,
1374                key_symbols: vec![],
1375            }],
1376            files_created: vec![],
1377            files_deleted: vec![],
1378            commit_hash: None,
1379        });
1380
1381        let result = build_knowledge_context(&knowledge).unwrap();
1382
1383        assert!(result.contains("**US-001**"));
1384        assert!(result.contains("**US-002**"));
1385        assert!(result.contains("+s/first.rs"));
1386        assert!(result.contains("~s/second.rs"));
1387    }
1388}