Skip to main content

aida_core/
report.rs

1// trace:FR-0259 | ai:claude:high
2//! AI Integration Report Generation
3//!
4//! Generates comprehensive reports documenting AI integration within a project:
5//! - Project overview and configuration
6//! - AI prompts and customizations
7//! - Code traceability summary
8//! - Scaffolding status and drift detection
9
10use 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/// Report output format
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ReportFormat {
20    Markdown,
21    Html,
22}
23
24/// Status of a scaffolded file compared to disk
25#[derive(Debug, Clone)]
26pub enum FileStatus {
27    /// File matches expected content
28    Match,
29    /// File exists but has been modified
30    Modified {
31        expected_lines: usize,
32        actual_lines: usize,
33    },
34    /// Expected file is missing from disk
35    Missing,
36    /// File exists on disk but not in scaffold (extra file)
37    Extra,
38}
39
40/// Result of comparing scaffold to actual project
41#[derive(Debug, Clone)]
42pub struct ScaffoldStatus {
43    /// Files that match exactly
44    pub matching: Vec<PathBuf>,
45    /// Files that have been modified
46    pub modified: Vec<(PathBuf, FileStatus)>,
47    /// Files that are missing
48    pub missing: Vec<PathBuf>,
49    /// Extra files in .claude directories not from scaffold
50    pub extra: Vec<PathBuf>,
51    /// Whether scaffold is up-to-date
52    pub is_current: bool,
53}
54
55impl ScaffoldStatus {
56    /// Create a new empty scaffold status
57    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/// Traceability statistics
75#[derive(Debug, Clone, Default)]
76pub struct TraceabilityStats {
77    /// Total number of trace links
78    pub total_links: usize,
79    /// Links by artifact type
80    pub by_type: HashMap<String, usize>,
81    /// Links by confidence level
82    pub by_confidence: HashMap<String, usize>,
83    /// Requirements with trace links
84    pub requirements_with_links: usize,
85    /// Requirements without trace links
86    pub requirements_without_links: usize,
87    /// Unique files referenced
88    pub unique_files: usize,
89}
90
91/// AI Integration Report data
92#[derive(Debug, Clone)]
93pub struct AiIntegrationReport {
94    /// Project name
95    pub project_name: String,
96    /// Project description
97    pub project_description: String,
98    /// Database path
99    pub database_path: String,
100    /// Generation timestamp
101    pub generated_at: chrono::DateTime<chrono::Utc>,
102    /// Total requirements count
103    pub total_requirements: usize,
104    /// AI prompts configuration
105    pub ai_prompts: AiPromptsSection,
106    /// Traceability statistics
107    pub traceability: TraceabilityStats,
108    /// Trace links grouped by requirement
109    pub trace_links_by_req: Vec<(String, String, Vec<TraceLink>)>, // (spec_id, title, links)
110    /// Scaffolding status
111    pub scaffold_status: Option<ScaffoldStatus>,
112    /// Scaffolding configuration
113    pub scaffold_config: Option<ScaffoldConfig>,
114    /// Type definitions summary
115    pub type_definitions: Vec<(String, String)>, // (name, description)
116    /// Features summary
117    pub features: Vec<(String, String)>, // (name, prefix)
118}
119
120/// AI Prompts configuration section
121#[derive(Debug, Clone, Default)]
122pub struct AiPromptsSection {
123    /// Global context
124    pub global_context: Option<String>,
125    /// Evaluation prompt customization
126    pub evaluation: Option<PromptCustomization>,
127    /// Duplicates prompt customization
128    pub duplicates: Option<PromptCustomization>,
129    /// Relationships prompt customization
130    pub relationships: Option<PromptCustomization>,
131    /// Improve prompt customization
132    pub improve: Option<PromptCustomization>,
133    /// Generate children prompt customization
134    pub generate_children: Option<PromptCustomization>,
135    /// Type-specific customizations
136    pub type_prompts: Vec<TypePromptCustomization>,
137}
138
139/// Individual prompt customization
140#[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/// Type-specific prompt customization
148#[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
156/// Report generator
157pub struct ReportGenerator {
158    store: RequirementsStore,
159    project_root: Option<PathBuf>,
160    database_path: String,
161}
162
163impl ReportGenerator {
164    /// Create a new report generator
165    pub fn new(store: RequirementsStore, database_path: String) -> Self {
166        Self {
167            store,
168            project_root: None,
169            database_path,
170        }
171    }
172
173    /// Set project root for scaffolding status check
174    pub fn with_project_root(mut self, root: PathBuf) -> Self {
175        self.project_root = Some(root);
176        self
177    }
178
179    /// Generate the report data
180    pub fn generate(&self) -> AiIntegrationReport {
181        let now = chrono::Utc::now();
182
183        // Collect AI prompts configuration
184        let ai_prompts = self.collect_ai_prompts();
185
186        // Collect traceability stats
187        let (traceability, trace_links_by_req) = self.collect_traceability();
188
189        // Check scaffold status if project root is set
190        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        // Collect type definitions
197        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        // Collect features
205        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                    // Count by artifact type
371                    let type_name = format!("{:?}", link.artifact_type);
372                    *stats.by_type.entry(type_name).or_insert(0) += 1;
373
374                    // Count by confidence level (if available from notes)
375                    // Parse from notes like "AI tool: claude" or look for patterns
376                    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                    // Track unique files
387                    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        // Use default scaffold config
406        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        // Generate expected artifacts
412        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                // Read actual content
421                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        // Check for extra files in .claude directory
453        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                        // Check subdirectories (commands, skills)
465                        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    /// Render report as markdown
486    pub fn render_markdown(&self, report: &AiIntegrationReport) -> String {
487        let mut md = String::new();
488
489        // Title
490        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        // Project Overview
500        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        // Features
512        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        // Type Definitions
522        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        // AI Configuration
535        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        // Prompt Customizations
545        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        // Type-specific prompts
574        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        // Code Traceability
599        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        // Trace Links Detail
643        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        // Scaffolding Status
668        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        // Scaffold Configuration
737        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    /// Render report as HTML
772    pub fn render_html(&self, report: &AiIntegrationReport) -> String {
773        let markdown = self.render_markdown(report);
774
775        // Use pulldown-cmark for proper markdown to HTML conversion
776        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
851/// Check scaffold status for a project (standalone function for CLI)
852pub 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    // Check for extra files
903    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}