1use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use crate::models::{RequirementsStore, TraceLink};
15use crate::scaffolding::{ScaffoldConfig, ScaffoldPreview, Scaffolder};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ReportFormat {
20 Markdown,
21 Html,
22}
23
24#[derive(Debug, Clone)]
26pub enum FileStatus {
27 Match,
29 Modified {
31 expected_lines: usize,
32 actual_lines: usize,
33 },
34 Missing,
36 Extra,
38}
39
40#[derive(Debug, Clone)]
42pub struct ScaffoldStatus {
43 pub matching: Vec<PathBuf>,
45 pub modified: Vec<(PathBuf, FileStatus)>,
47 pub missing: Vec<PathBuf>,
49 pub extra: Vec<PathBuf>,
51 pub is_current: bool,
53}
54
55impl ScaffoldStatus {
56 pub fn new() -> Self {
58 Self {
59 matching: Vec::new(),
60 modified: Vec::new(),
61 missing: Vec::new(),
62 extra: Vec::new(),
63 is_current: true,
64 }
65 }
66}
67
68impl Default for ScaffoldStatus {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74#[derive(Debug, Clone, Default)]
76pub struct TraceabilityStats {
77 pub total_links: usize,
79 pub by_type: HashMap<String, usize>,
81 pub by_confidence: HashMap<String, usize>,
83 pub requirements_with_links: usize,
85 pub requirements_without_links: usize,
87 pub unique_files: usize,
89}
90
91#[derive(Debug, Clone)]
93pub struct AiIntegrationReport {
94 pub project_name: String,
96 pub project_description: String,
98 pub database_path: String,
100 pub generated_at: chrono::DateTime<chrono::Utc>,
102 pub total_requirements: usize,
104 pub ai_prompts: AiPromptsSection,
106 pub traceability: TraceabilityStats,
108 pub trace_links_by_req: Vec<(String, String, Vec<TraceLink>)>, pub scaffold_status: Option<ScaffoldStatus>,
112 pub scaffold_config: Option<ScaffoldConfig>,
114 pub type_definitions: Vec<(String, String)>, pub features: Vec<(String, String)>, }
119
120#[derive(Debug, Clone, Default)]
122pub struct AiPromptsSection {
123 pub global_context: Option<String>,
125 pub evaluation: Option<PromptCustomization>,
127 pub duplicates: Option<PromptCustomization>,
129 pub relationships: Option<PromptCustomization>,
131 pub improve: Option<PromptCustomization>,
133 pub generate_children: Option<PromptCustomization>,
135 pub type_prompts: Vec<TypePromptCustomization>,
137}
138
139#[derive(Debug, Clone)]
141pub struct PromptCustomization {
142 pub action_name: String,
143 pub custom_template: Option<String>,
144 pub additional_instructions: Option<String>,
145}
146
147#[derive(Debug, Clone)]
149pub struct TypePromptCustomization {
150 pub type_name: String,
151 pub evaluation_extra: Option<String>,
152 pub improve_extra: Option<String>,
153 pub generate_children_extra: Option<String>,
154}
155
156pub struct ReportGenerator {
158 store: RequirementsStore,
159 project_root: Option<PathBuf>,
160 database_path: String,
161}
162
163impl ReportGenerator {
164 pub fn new(store: RequirementsStore, database_path: String) -> Self {
166 Self {
167 store,
168 project_root: None,
169 database_path,
170 }
171 }
172
173 pub fn with_project_root(mut self, root: PathBuf) -> Self {
175 self.project_root = Some(root);
176 self
177 }
178
179 pub fn generate(&self) -> AiIntegrationReport {
181 let now = chrono::Utc::now();
182
183 let ai_prompts = self.collect_ai_prompts();
185
186 let (traceability, trace_links_by_req) = self.collect_traceability();
188
189 let (scaffold_status, scaffold_config) = if let Some(ref root) = self.project_root {
191 self.check_scaffold_status(root)
192 } else {
193 (None, None)
194 };
195
196 let type_definitions: Vec<_> = self
198 .store
199 .type_definitions
200 .iter()
201 .map(|td| (td.name.clone(), td.description.clone().unwrap_or_default()))
202 .collect();
203
204 let features: Vec<_> = self
206 .store
207 .features
208 .iter()
209 .map(|f| (f.name.clone(), f.prefix.clone()))
210 .collect();
211
212 AiIntegrationReport {
213 project_name: self.store.name.clone(),
214 project_description: self.store.description.clone(),
215 database_path: self.database_path.clone(),
216 generated_at: now,
217 total_requirements: self.store.requirements.len(),
218 ai_prompts,
219 traceability,
220 trace_links_by_req,
221 scaffold_status,
222 scaffold_config,
223 type_definitions,
224 features,
225 }
226 }
227
228 fn collect_ai_prompts(&self) -> AiPromptsSection {
229 let config = &self.store.ai_prompts;
230
231 let global_context = if config.global_context.is_empty() {
232 None
233 } else {
234 Some(config.global_context.clone())
235 };
236
237 let evaluation = if config.evaluation.custom_template.is_some()
238 || !config.evaluation.additional_instructions.is_empty()
239 {
240 Some(PromptCustomization {
241 action_name: "Evaluation".to_string(),
242 custom_template: config.evaluation.custom_template.clone(),
243 additional_instructions: if config.evaluation.additional_instructions.is_empty() {
244 None
245 } else {
246 Some(config.evaluation.additional_instructions.clone())
247 },
248 })
249 } else {
250 None
251 };
252
253 let duplicates = if config.duplicates.custom_template.is_some()
254 || !config.duplicates.additional_instructions.is_empty()
255 {
256 Some(PromptCustomization {
257 action_name: "Find Duplicates".to_string(),
258 custom_template: config.duplicates.custom_template.clone(),
259 additional_instructions: if config.duplicates.additional_instructions.is_empty() {
260 None
261 } else {
262 Some(config.duplicates.additional_instructions.clone())
263 },
264 })
265 } else {
266 None
267 };
268
269 let relationships = if config.relationships.custom_template.is_some()
270 || !config.relationships.additional_instructions.is_empty()
271 {
272 Some(PromptCustomization {
273 action_name: "Suggest Relationships".to_string(),
274 custom_template: config.relationships.custom_template.clone(),
275 additional_instructions: if config.relationships.additional_instructions.is_empty()
276 {
277 None
278 } else {
279 Some(config.relationships.additional_instructions.clone())
280 },
281 })
282 } else {
283 None
284 };
285
286 let improve = if config.improve.custom_template.is_some()
287 || !config.improve.additional_instructions.is_empty()
288 {
289 Some(PromptCustomization {
290 action_name: "Improve Description".to_string(),
291 custom_template: config.improve.custom_template.clone(),
292 additional_instructions: if config.improve.additional_instructions.is_empty() {
293 None
294 } else {
295 Some(config.improve.additional_instructions.clone())
296 },
297 })
298 } else {
299 None
300 };
301
302 let generate_children = if config.generate_children.custom_template.is_some()
303 || !config.generate_children.additional_instructions.is_empty()
304 {
305 Some(PromptCustomization {
306 action_name: "Generate Children".to_string(),
307 custom_template: config.generate_children.custom_template.clone(),
308 additional_instructions: if config
309 .generate_children
310 .additional_instructions
311 .is_empty()
312 {
313 None
314 } else {
315 Some(config.generate_children.additional_instructions.clone())
316 },
317 })
318 } else {
319 None
320 };
321
322 let type_prompts: Vec<_> = config
323 .type_prompts
324 .iter()
325 .map(|tp| TypePromptCustomization {
326 type_name: tp.type_name.clone(),
327 evaluation_extra: if tp.evaluation_extra.is_empty() {
328 None
329 } else {
330 Some(tp.evaluation_extra.clone())
331 },
332 improve_extra: if tp.improve_extra.is_empty() {
333 None
334 } else {
335 Some(tp.improve_extra.clone())
336 },
337 generate_children_extra: if tp.generate_children_extra.is_empty() {
338 None
339 } else {
340 Some(tp.generate_children_extra.clone())
341 },
342 })
343 .collect();
344
345 AiPromptsSection {
346 global_context,
347 evaluation,
348 duplicates,
349 relationships,
350 improve,
351 generate_children,
352 type_prompts,
353 }
354 }
355
356 fn collect_traceability(&self) -> (TraceabilityStats, Vec<(String, String, Vec<TraceLink>)>) {
357 let mut stats = TraceabilityStats::default();
358 let mut trace_links_by_req = Vec::new();
359 let mut unique_files = std::collections::HashSet::new();
360
361 for req in &self.store.requirements {
362 if !req.trace_links.is_empty() {
363 stats.requirements_with_links += 1;
364 let spec_id = req.spec_id.clone().unwrap_or_else(|| req.id.to_string());
365 trace_links_by_req.push((spec_id, req.title.clone(), req.trace_links.clone()));
366
367 for link in &req.trace_links {
368 stats.total_links += 1;
369
370 let type_name = format!("{:?}", link.artifact_type);
372 *stats.by_type.entry(type_name).or_insert(0) += 1;
373
374 if let Some(notes) = &link.notes {
377 if notes.contains("high") || notes.to_lowercase().contains("ai tool") {
378 *stats.by_confidence.entry("High".to_string()).or_insert(0) += 1;
379 } else if notes.contains("med") {
380 *stats.by_confidence.entry("Medium".to_string()).or_insert(0) += 1;
381 } else if notes.contains("low") {
382 *stats.by_confidence.entry("Low".to_string()).or_insert(0) += 1;
383 }
384 }
385
386 if !link.file_path.is_empty() {
388 unique_files.insert(link.file_path.clone());
389 }
390 }
391 } else {
392 stats.requirements_without_links += 1;
393 }
394 }
395
396 stats.unique_files = unique_files.len();
397
398 (stats, trace_links_by_req)
399 }
400
401 fn check_scaffold_status(
402 &self,
403 project_root: &Path,
404 ) -> (Option<ScaffoldStatus>, Option<ScaffoldConfig>) {
405 let config = ScaffoldConfig::default();
407 let db_path = PathBuf::from(&self.database_path);
408 let mut scaffolder =
409 Scaffolder::with_database(project_root.to_path_buf(), config.clone(), db_path);
410
411 let preview = scaffolder.preview(&self.store);
413
414 let mut status = ScaffoldStatus::new();
415
416 for artifact in &preview.artifacts {
417 let full_path = project_root.join(&artifact.path);
418
419 if full_path.exists() {
420 if let Ok(actual_content) = fs::read_to_string(&full_path) {
422 if actual_content.trim() == artifact.content.trim() {
423 status.matching.push(artifact.path.clone());
424 } else {
425 let expected_lines = artifact.content.lines().count();
426 let actual_lines = actual_content.lines().count();
427 status.modified.push((
428 artifact.path.clone(),
429 FileStatus::Modified {
430 expected_lines,
431 actual_lines,
432 },
433 ));
434 status.is_current = false;
435 }
436 } else {
437 status.modified.push((
438 artifact.path.clone(),
439 FileStatus::Modified {
440 expected_lines: artifact.content.lines().count(),
441 actual_lines: 0,
442 },
443 ));
444 status.is_current = false;
445 }
446 } else {
447 status.missing.push(artifact.path.clone());
448 status.is_current = false;
449 }
450 }
451
452 let claude_dir = project_root.join(".claude");
454 if claude_dir.exists() {
455 if let Ok(entries) = fs::read_dir(&claude_dir) {
456 for entry in entries.flatten() {
457 let path = entry.path();
458 if path.is_file() {
459 let rel_path = path.strip_prefix(project_root).unwrap_or(&path);
460 if !preview.artifacts.iter().any(|a| a.path == rel_path) {
461 status.extra.push(rel_path.to_path_buf());
462 }
463 } else if path.is_dir() {
464 if let Ok(sub_entries) = fs::read_dir(&path) {
466 for sub_entry in sub_entries.flatten() {
467 let sub_path = sub_entry.path();
468 if sub_path.is_file() {
469 let rel_path =
470 sub_path.strip_prefix(project_root).unwrap_or(&sub_path);
471 if !preview.artifacts.iter().any(|a| a.path == rel_path) {
472 status.extra.push(rel_path.to_path_buf());
473 }
474 }
475 }
476 }
477 }
478 }
479 }
480 }
481
482 (Some(status), Some(config))
483 }
484
485 pub fn render_markdown(&self, report: &AiIntegrationReport) -> String {
487 let mut md = String::new();
488
489 md.push_str(&format!(
491 "# AI Integration Report: {}\n\n",
492 report.project_name
493 ));
494 md.push_str(&format!(
495 "*Generated: {}*\n\n",
496 report.generated_at.format("%Y-%m-%d %H:%M UTC")
497 ));
498
499 md.push_str("## Project Overview\n\n");
501 md.push_str(&format!("- **Database**: `{}`\n", report.database_path));
502 md.push_str(&format!(
503 "- **Total Requirements**: {}\n",
504 report.total_requirements
505 ));
506 if !report.project_description.is_empty() {
507 md.push_str(&format!("\n{}\n", report.project_description));
508 }
509 md.push_str("\n");
510
511 if !report.features.is_empty() {
513 md.push_str("### Features\n\n");
514 md.push_str("| Feature | Prefix |\n|---------|--------|\n");
515 for (name, prefix) in &report.features {
516 md.push_str(&format!("| {} | {} |\n", name, prefix));
517 }
518 md.push_str("\n");
519 }
520
521 if !report.type_definitions.is_empty() {
523 md.push_str("### Requirement Types\n\n");
524 for (name, desc) in &report.type_definitions {
525 if desc.is_empty() {
526 md.push_str(&format!("- **{}**\n", name));
527 } else {
528 md.push_str(&format!("- **{}**: {}\n", name, desc));
529 }
530 }
531 md.push_str("\n");
532 }
533
534 md.push_str("## AI Configuration\n\n");
536
537 if let Some(ref global_ctx) = report.ai_prompts.global_context {
538 md.push_str("### Global Context\n\n");
539 md.push_str("```\n");
540 md.push_str(global_ctx);
541 md.push_str("\n```\n\n");
542 }
543
544 let customizations: Vec<&PromptCustomization> = [
546 report.ai_prompts.evaluation.as_ref(),
547 report.ai_prompts.duplicates.as_ref(),
548 report.ai_prompts.relationships.as_ref(),
549 report.ai_prompts.improve.as_ref(),
550 report.ai_prompts.generate_children.as_ref(),
551 ]
552 .into_iter()
553 .flatten()
554 .collect();
555
556 if !customizations.is_empty() {
557 md.push_str("### Prompt Customizations\n\n");
558 for cust in &customizations {
559 md.push_str(&format!("#### {}\n\n", cust.action_name));
560 if let Some(ref template) = cust.custom_template {
561 md.push_str("**Custom Template:**\n```\n");
562 md.push_str(template);
563 md.push_str("\n```\n\n");
564 }
565 if let Some(ref instructions) = cust.additional_instructions {
566 md.push_str("**Additional Instructions:**\n```\n");
567 md.push_str(instructions);
568 md.push_str("\n```\n\n");
569 }
570 }
571 }
572
573 if !report.ai_prompts.type_prompts.is_empty() {
575 md.push_str("### Type-Specific Customizations\n\n");
576 for tp in &report.ai_prompts.type_prompts {
577 md.push_str(&format!("#### Type: {}\n\n", tp.type_name));
578 if let Some(ref eval) = tp.evaluation_extra {
579 md.push_str(&format!("- **Evaluation Extra**: {}\n", eval));
580 }
581 if let Some(ref imp) = tp.improve_extra {
582 md.push_str(&format!("- **Improve Extra**: {}\n", imp));
583 }
584 if let Some(ref gen) = tp.generate_children_extra {
585 md.push_str(&format!("- **Generate Children Extra**: {}\n", gen));
586 }
587 md.push_str("\n");
588 }
589 }
590
591 if report.ai_prompts.global_context.is_none()
592 && customizations.is_empty()
593 && report.ai_prompts.type_prompts.is_empty()
594 {
595 md.push_str("*Using default AI prompts - no customizations configured.*\n\n");
596 }
597
598 md.push_str("## Code Traceability\n\n");
600
601 md.push_str("### Statistics\n\n");
602 md.push_str(&format!(
603 "- **Total Trace Links**: {}\n",
604 report.traceability.total_links
605 ));
606 md.push_str(&format!(
607 "- **Requirements with Links**: {} ({:.1}%)\n",
608 report.traceability.requirements_with_links,
609 if report.total_requirements > 0 {
610 (report.traceability.requirements_with_links as f64
611 / report.total_requirements as f64)
612 * 100.0
613 } else {
614 0.0
615 }
616 ));
617 md.push_str(&format!(
618 "- **Requirements without Links**: {}\n",
619 report.traceability.requirements_without_links
620 ));
621 md.push_str(&format!(
622 "- **Unique Files Referenced**: {}\n\n",
623 report.traceability.unique_files
624 ));
625
626 if !report.traceability.by_type.is_empty() {
627 md.push_str("#### By Artifact Type\n\n");
628 for (type_name, count) in &report.traceability.by_type {
629 md.push_str(&format!("- {}: {}\n", type_name, count));
630 }
631 md.push_str("\n");
632 }
633
634 if !report.traceability.by_confidence.is_empty() {
635 md.push_str("#### By Confidence Level\n\n");
636 for (level, count) in &report.traceability.by_confidence {
637 md.push_str(&format!("- {}: {}\n", level, count));
638 }
639 md.push_str("\n");
640 }
641
642 if !report.trace_links_by_req.is_empty() {
644 md.push_str("### Trace Links by Requirement\n\n");
645 for (spec_id, title, links) in &report.trace_links_by_req {
646 md.push_str(&format!("#### {} - {}\n\n", spec_id, title));
647 for link in links {
648 let line_info = match (link.line_start, link.line_end) {
649 (Some(start), Some(end)) => format!(":{}–{}", start, end),
650 (Some(start), None) => format!(":{}", start),
651 _ => String::new(),
652 };
653 md.push_str(&format!("- `{}{}`", link.file_path, line_info));
654 if let Some(ref symbol) = link.symbol {
655 md.push_str(&format!(" (`{}`)", symbol));
656 }
657 md.push_str(&format!(" - {:?}", link.artifact_type));
658 if let Some(ref notes) = link.notes {
659 md.push_str(&format!(" - *{}*", notes));
660 }
661 md.push_str("\n");
662 }
663 md.push_str("\n");
664 }
665 }
666
667 if let Some(ref status) = report.scaffold_status {
669 md.push_str("## Scaffolding Status\n\n");
670
671 if status.is_current {
672 md.push_str("**Status: Up to date**\n\n");
673 } else {
674 md.push_str("**Status: Drift detected**\n\n");
675 }
676
677 md.push_str(&format!(
678 "- **Matching Files**: {}\n",
679 status.matching.len()
680 ));
681 md.push_str(&format!(
682 "- **Modified Files**: {}\n",
683 status.modified.len()
684 ));
685 md.push_str(&format!("- **Missing Files**: {}\n", status.missing.len()));
686 md.push_str(&format!("- **Extra Files**: {}\n\n", status.extra.len()));
687
688 if !status.matching.is_empty() {
689 md.push_str("### Matching Files\n\n");
690 for path in &status.matching {
691 md.push_str(&format!("- `{}`\n", path.display()));
692 }
693 md.push_str("\n");
694 }
695
696 if !status.modified.is_empty() {
697 md.push_str("### Modified Files\n\n");
698 for (path, file_status) in &status.modified {
699 match file_status {
700 FileStatus::Modified {
701 expected_lines,
702 actual_lines,
703 } => {
704 md.push_str(&format!(
705 "- `{}` (expected {} lines, found {} lines)\n",
706 path.display(),
707 expected_lines,
708 actual_lines
709 ));
710 }
711 _ => {
712 md.push_str(&format!("- `{}`\n", path.display()));
713 }
714 }
715 }
716 md.push_str("\n");
717 }
718
719 if !status.missing.is_empty() {
720 md.push_str("### Missing Files\n\n");
721 for path in &status.missing {
722 md.push_str(&format!("- `{}`\n", path.display()));
723 }
724 md.push_str("\n");
725 }
726
727 if !status.extra.is_empty() {
728 md.push_str("### Extra Files (not from scaffold)\n\n");
729 for path in &status.extra {
730 md.push_str(&format!("- `{}`\n", path.display()));
731 }
732 md.push_str("\n");
733 }
734 }
735
736 if let Some(ref config) = report.scaffold_config {
738 md.push_str("### Scaffold Configuration\n\n");
739 md.push_str(&format!("- **Project Type**: {:?}\n", config.project_type));
740 md.push_str(&format!(
741 "- **Generate CLAUDE.md**: {}\n",
742 config.generate_claude_md
743 ));
744 md.push_str(&format!(
745 "- **Generate Commands**: {}\n",
746 config.generate_commands
747 ));
748 md.push_str(&format!(
749 "- **Generate Skills**: {}\n",
750 config.generate_skills
751 ));
752 md.push_str(&format!(
753 "- **Generate Git Hooks**: {}\n",
754 config.generate_git_hooks
755 ));
756 if !config.tech_stack.is_empty() {
757 md.push_str(&format!(
758 "- **Tech Stack**: {}\n",
759 config.tech_stack.join(", ")
760 ));
761 }
762 md.push_str("\n");
763 }
764
765 md.push_str("---\n\n");
766 md.push_str("*This report was generated by AIDA (AI Design Assistant)*\n");
767
768 md
769 }
770
771 pub fn render_html(&self, report: &AiIntegrationReport) -> String {
773 let markdown = self.render_markdown(report);
774
775 use pulldown_cmark::{html, Options, Parser};
777
778 let mut options = Options::empty();
779 options.insert(Options::ENABLE_STRIKETHROUGH);
780 options.insert(Options::ENABLE_TABLES);
781
782 let parser = Parser::new_ext(&markdown, options);
783 let mut html_body = String::new();
784 html::push_html(&mut html_body, parser);
785
786 format!(
787 r#"<!DOCTYPE html>
788<html lang="en">
789<head>
790 <meta charset="UTF-8">
791 <meta name="viewport" content="width=device-width, initial-scale=1.0">
792 <title>AI Integration Report: {}</title>
793 <style>
794 body {{
795 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
796 line-height: 1.6;
797 max-width: 900px;
798 margin: 0 auto;
799 padding: 20px;
800 color: #333;
801 }}
802 h1 {{ color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }}
803 h2 {{ color: #34495e; margin-top: 30px; }}
804 h3 {{ color: #7f8c8d; }}
805 h4 {{ color: #95a5a6; }}
806 pre {{
807 background: #f4f4f4;
808 padding: 15px;
809 border-radius: 5px;
810 overflow-x: auto;
811 }}
812 code {{
813 background: #f4f4f4;
814 padding: 2px 5px;
815 border-radius: 3px;
816 font-family: 'Fira Code', monospace;
817 }}
818 pre code {{
819 background: none;
820 padding: 0;
821 }}
822 table {{
823 border-collapse: collapse;
824 width: 100%;
825 margin: 15px 0;
826 }}
827 th, td {{
828 border: 1px solid #ddd;
829 padding: 8px;
830 text-align: left;
831 }}
832 th {{
833 background: #f4f4f4;
834 }}
835 li {{
836 margin: 5px 0;
837 }}
838 .status-current {{ color: #27ae60; }}
839 .status-drift {{ color: #e74c3c; }}
840 </style>
841</head>
842<body>
843{}
844</body>
845</html>"#,
846 report.project_name, html_body
847 )
848 }
849}
850
851pub fn check_scaffold_status(
853 store: &RequirementsStore,
854 project_root: &Path,
855 config: &ScaffoldConfig,
856 database_path: &Path,
857) -> ScaffoldStatus {
858 let mut scaffolder = Scaffolder::with_database(
859 project_root.to_path_buf(),
860 config.clone(),
861 database_path.to_path_buf(),
862 );
863 let preview = scaffolder.preview(store);
864
865 let mut status = ScaffoldStatus::new();
866
867 for artifact in &preview.artifacts {
868 let full_path = project_root.join(&artifact.path);
869
870 if full_path.exists() {
871 if let Ok(actual_content) = fs::read_to_string(&full_path) {
872 if actual_content.trim() == artifact.content.trim() {
873 status.matching.push(artifact.path.clone());
874 } else {
875 let expected_lines = artifact.content.lines().count();
876 let actual_lines = actual_content.lines().count();
877 status.modified.push((
878 artifact.path.clone(),
879 FileStatus::Modified {
880 expected_lines,
881 actual_lines,
882 },
883 ));
884 status.is_current = false;
885 }
886 } else {
887 status.modified.push((
888 artifact.path.clone(),
889 FileStatus::Modified {
890 expected_lines: artifact.content.lines().count(),
891 actual_lines: 0,
892 },
893 ));
894 status.is_current = false;
895 }
896 } else {
897 status.missing.push(artifact.path.clone());
898 status.is_current = false;
899 }
900 }
901
902 let claude_dir = project_root.join(".claude");
904 if claude_dir.exists() {
905 scan_extra_files(&claude_dir, project_root, &preview, &mut status.extra);
906 }
907
908 status
909}
910
911fn scan_extra_files(
912 dir: &Path,
913 project_root: &Path,
914 preview: &ScaffoldPreview,
915 extra: &mut Vec<PathBuf>,
916) {
917 if let Ok(entries) = fs::read_dir(dir) {
918 for entry in entries.flatten() {
919 let path = entry.path();
920 if path.is_file() {
921 let rel_path = path.strip_prefix(project_root).unwrap_or(&path);
922 if !preview.artifacts.iter().any(|a| a.path == rel_path) {
923 extra.push(rel_path.to_path_buf());
924 }
925 } else if path.is_dir() {
926 scan_extra_files(&path, project_root, preview, extra);
927 }
928 }
929 }
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935
936 #[test]
937 fn test_scaffold_status_new() {
938 let status = ScaffoldStatus::new();
939 assert!(status.is_current);
940 assert!(status.matching.is_empty());
941 assert!(status.modified.is_empty());
942 assert!(status.missing.is_empty());
943 assert!(status.extra.is_empty());
944 }
945
946 #[test]
947 fn test_traceability_stats_default() {
948 let stats = TraceabilityStats::default();
949 assert_eq!(stats.total_links, 0);
950 assert_eq!(stats.requirements_with_links, 0);
951 }
952}