1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22#[serde(rename_all = "camelCase")]
23pub struct FileContextEntry {
24 pub path: PathBuf,
26 pub purpose: String,
28 pub key_symbols: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35#[serde(rename_all = "camelCase")]
36pub struct Decision {
37 pub topic: String,
39 pub choice: String,
41 pub rationale: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48#[serde(rename_all = "camelCase")]
49pub struct Pattern {
50 pub description: String,
52}
53const MAX_WORK_SUMMARY_LENGTH: usize = 500;
54
55pub 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 let truncated = if summary.len() > MAX_WORK_SUMMARY_LENGTH {
71 let mut end = MAX_WORK_SUMMARY_LENGTH;
72 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
84pub 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
141pub 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
190pub 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
231fn parse_symbol_list(input: &str) -> Vec<String> {
233 let trimmed = input.trim();
234
235 if trimmed.is_empty() || trimmed == "[]" {
237 return Vec::new();
238 }
239
240 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
258pub 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
278pub fn build_knowledge_context(knowledge: &ProjectKnowledge) -> Option<String> {
289 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 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 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 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 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 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
410fn 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
420fn 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
429pub fn fix_json_syntax(input: &str) -> String {
437 use regex::Regex;
438
439 let mut result = input.to_string();
440
441 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 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 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: ®ex::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 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
477pub fn extract_json(response: &str) -> Option<String> {
479 let trimmed = response.trim();
480
481 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 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 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
521pub 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 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")); assert!(context.contains("~s/lib.rs")); assert!(context.contains("-s/old.rs")); }
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 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 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 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 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 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 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 assert!(result.contains("..."));
1260 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 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 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 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}