data_modelling_core/export/
markdown.rs

1//! Markdown exporter for Decision and Knowledge articles
2//!
3//! Exports Decision and KnowledgeArticle models to Markdown format
4//! for easy reading on GitHub and other platforms.
5
6use crate::export::ExportError;
7use crate::models::decision::{Decision, DecisionStatus, DriverPriority};
8use crate::models::knowledge::{KnowledgeArticle, KnowledgeStatus, KnowledgeType};
9
10/// Markdown exporter for decisions and knowledge articles
11pub struct MarkdownExporter;
12
13impl MarkdownExporter {
14    /// Create a new Markdown exporter instance
15    pub fn new() -> Self {
16        Self
17    }
18
19    /// Export a decision to MADR-compliant Markdown format
20    ///
21    /// # Arguments
22    ///
23    /// * `decision` - The Decision to export
24    ///
25    /// # Returns
26    ///
27    /// A Markdown string following MADR template format
28    pub fn export_decision(&self, decision: &Decision) -> Result<String, ExportError> {
29        let mut md = String::new();
30
31        // Title with status badge
32        let status_badge = match decision.status {
33            DecisionStatus::Draft => "⚪ Draft",
34            DecisionStatus::Proposed => "🟡 Proposed",
35            DecisionStatus::Accepted => "🟢 Accepted",
36            DecisionStatus::Deprecated => "🔴 Deprecated",
37            DecisionStatus::Superseded => "⚫ Superseded",
38            DecisionStatus::Rejected => "🔴 Rejected",
39        };
40
41        md.push_str(&format!(
42            "# {}: {}\n\n",
43            decision.formatted_number(),
44            decision.title
45        ));
46
47        // Metadata table
48        md.push_str("| Property | Value |\n");
49        md.push_str("|----------|-------|\n");
50        md.push_str(&format!("| **Status** | {} |\n", status_badge));
51        md.push_str(&format!("| **Category** | {} |\n", decision.category));
52        if let Some(domain) = &decision.domain {
53            md.push_str(&format!("| **Domain** | {} |\n", domain));
54        }
55        md.push_str(&format!(
56            "| **Date** | {} |\n",
57            decision.date.format("%Y-%m-%d")
58        ));
59        if !decision.authors.is_empty() {
60            md.push_str(&format!(
61                "| **Authors** | {} |\n",
62                decision.authors.join(", ")
63            ));
64        }
65        if !decision.deciders.is_empty() {
66            md.push_str(&format!(
67                "| **Deciders** | {} |\n",
68                decision.deciders.join(", ")
69            ));
70        }
71        md.push('\n');
72
73        // Consulted/Informed section (if present)
74        if !decision.consulted.is_empty() || !decision.informed.is_empty() {
75            md.push_str("## Stakeholders\n\n");
76            md.push_str("| Role | Participants |\n");
77            md.push_str("|------|-------------|\n");
78            if !decision.deciders.is_empty() {
79                md.push_str(&format!(
80                    "| **Deciders** | {} |\n",
81                    decision.deciders.join(", ")
82                ));
83            }
84            if !decision.consulted.is_empty() {
85                md.push_str(&format!(
86                    "| **Consulted** | {} |\n",
87                    decision.consulted.join(", ")
88                ));
89            }
90            if !decision.informed.is_empty() {
91                md.push_str(&format!(
92                    "| **Informed** | {} |\n",
93                    decision.informed.join(", ")
94                ));
95            }
96            md.push('\n');
97        }
98
99        // Context section
100        md.push_str("## Context\n\n");
101        md.push_str(&decision.context);
102        md.push_str("\n\n");
103
104        // Decision Drivers section (if any)
105        if !decision.drivers.is_empty() {
106            md.push_str("## Decision Drivers\n\n");
107            for driver in &decision.drivers {
108                let priority_str = match &driver.priority {
109                    Some(DriverPriority::High) => " *(High Priority)*",
110                    Some(DriverPriority::Medium) => " *(Medium Priority)*",
111                    Some(DriverPriority::Low) => " *(Low Priority)*",
112                    None => "",
113                };
114                md.push_str(&format!("- {}{}\n", driver.description, priority_str));
115            }
116            md.push('\n');
117        }
118
119        // Considered Options section (if any)
120        if !decision.options.is_empty() {
121            md.push_str("## Considered Options\n\n");
122            for option in &decision.options {
123                let selected_marker = if option.selected { " ✓" } else { "" };
124                md.push_str(&format!("### {}{}\n\n", option.name, selected_marker));
125
126                if let Some(desc) = &option.description {
127                    md.push_str(desc);
128                    md.push_str("\n\n");
129                }
130
131                if !option.pros.is_empty() {
132                    md.push_str("**Pros:**\n");
133                    for pro in &option.pros {
134                        md.push_str(&format!("- ✅ {}\n", pro));
135                    }
136                    md.push('\n');
137                }
138
139                if !option.cons.is_empty() {
140                    md.push_str("**Cons:**\n");
141                    for con in &option.cons {
142                        md.push_str(&format!("- ❌ {}\n", con));
143                    }
144                    md.push('\n');
145                }
146            }
147        }
148
149        // Decision section
150        md.push_str("## Decision\n\n");
151        md.push_str(&decision.decision);
152        md.push_str("\n\n");
153
154        // Consequences section (if any)
155        if let Some(consequences) = &decision.consequences {
156            md.push_str("## Consequences\n\n");
157            md.push_str(consequences);
158            md.push_str("\n\n");
159        }
160
161        // Linked Assets section (if any)
162        if !decision.linked_assets.is_empty() {
163            md.push_str("## Linked Assets\n\n");
164            md.push_str("| Asset Type | Asset Name | Relationship |\n");
165            md.push_str("|------------|------------|---------------|\n");
166            for link in &decision.linked_assets {
167                let rel_str = link
168                    .relationship
169                    .as_ref()
170                    .map(|r| format!("{:?}", r))
171                    .unwrap_or_else(|| "-".to_string());
172                md.push_str(&format!(
173                    "| {} | {} | {} |\n",
174                    link.asset_type, link.asset_name, rel_str
175                ));
176            }
177            md.push('\n');
178        }
179
180        // Supersession info
181        if let Some(supersedes) = &decision.supersedes {
182            md.push_str(&format!(
183                "> **Note:** This decision supersedes `{}`\n\n",
184                supersedes
185            ));
186        }
187        if let Some(superseded_by) = &decision.superseded_by {
188            md.push_str(&format!(
189                "> **Warning:** This decision has been superseded by `{}`\n\n",
190                superseded_by
191            ));
192        }
193
194        // Compliance section (if any)
195        if let Some(compliance) = &decision.compliance {
196            md.push_str("## Compliance Assessment\n\n");
197            if let Some(reg) = &compliance.regulatory_impact {
198                md.push_str(&format!("**Regulatory Impact:** {}\n\n", reg));
199            }
200            if let Some(priv_assess) = &compliance.privacy_assessment {
201                md.push_str(&format!("**Privacy Assessment:** {}\n\n", priv_assess));
202            }
203            if let Some(sec_assess) = &compliance.security_assessment {
204                md.push_str(&format!("**Security Assessment:** {}\n\n", sec_assess));
205            }
206            if !compliance.frameworks.is_empty() {
207                md.push_str(&format!(
208                    "**Frameworks:** {}\n\n",
209                    compliance.frameworks.join(", ")
210                ));
211            }
212        }
213
214        // Tags (if any)
215        if !decision.tags.is_empty() {
216            let tags_str: Vec<String> = decision.tags.iter().map(|t| format!("`{}`", t)).collect();
217            md.push_str(&format!("**Tags:** {}\n\n", tags_str.join(" ")));
218        }
219
220        // Footer with timestamps
221        md.push_str("---\n\n");
222        md.push_str(&format!(
223            "*Created: {} | Last Updated: {}*\n",
224            decision.created_at.format("%Y-%m-%d %H:%M UTC"),
225            decision.updated_at.format("%Y-%m-%d %H:%M UTC")
226        ));
227
228        if let Some(conf_date) = &decision.confirmation_date {
229            md.push_str(&format!(
230                "\n*Last Confirmed: {}*",
231                conf_date.format("%Y-%m-%d")
232            ));
233            if let Some(notes) = &decision.confirmation_notes {
234                md.push_str(&format!(" - {}", notes));
235            }
236            md.push('\n');
237        }
238
239        Ok(md)
240    }
241
242    /// Export a knowledge article to Markdown format
243    ///
244    /// # Arguments
245    ///
246    /// * `article` - The KnowledgeArticle to export
247    ///
248    /// # Returns
249    ///
250    /// A Markdown string
251    pub fn export_knowledge(&self, article: &KnowledgeArticle) -> Result<String, ExportError> {
252        let mut md = String::new();
253
254        // Title with type badge
255        let type_badge = match article.article_type {
256            KnowledgeType::Guide => "📖 Guide",
257            KnowledgeType::Standard => "📋 Standard",
258            KnowledgeType::Reference => "📚 Reference",
259            KnowledgeType::HowTo => "🔧 How-To",
260            KnowledgeType::Troubleshooting => "🔍 Troubleshooting",
261            KnowledgeType::Policy => "⚖️ Policy",
262            KnowledgeType::Template => "📄 Template",
263            KnowledgeType::Concept => "💡 Concept",
264            KnowledgeType::Runbook => "📓 Runbook",
265            KnowledgeType::Tutorial => "🎓 Tutorial",
266            KnowledgeType::Glossary => "📝 Glossary",
267        };
268
269        let status_badge = match article.status {
270            KnowledgeStatus::Draft => "🟡 Draft",
271            KnowledgeStatus::Review => "🟠 Review",
272            KnowledgeStatus::Published => "🟢 Published",
273            KnowledgeStatus::Archived => "📦 Archived",
274            KnowledgeStatus::Deprecated => "🔴 Deprecated",
275        };
276
277        md.push_str(&format!(
278            "# {}: {}\n\n",
279            article.formatted_number(),
280            article.title
281        ));
282
283        // Metadata table
284        md.push_str("| Property | Value |\n");
285        md.push_str("|----------|-------|\n");
286        md.push_str(&format!("| **Type** | {} |\n", type_badge));
287        md.push_str(&format!("| **Status** | {} |\n", status_badge));
288        if let Some(domain) = &article.domain {
289            md.push_str(&format!("| **Domain** | {} |\n", domain));
290        }
291        if !article.authors.is_empty() {
292            md.push_str(&format!(
293                "| **Authors** | {} |\n",
294                article.authors.join(", ")
295            ));
296        }
297        if let Some(skill) = &article.skill_level {
298            md.push_str(&format!("| **Skill Level** | {} |\n", skill));
299        }
300        if !article.audience.is_empty() {
301            md.push_str(&format!(
302                "| **Audience** | {} |\n",
303                article.audience.join(", ")
304            ));
305        }
306        md.push('\n');
307
308        // Summary section
309        md.push_str("## Summary\n\n");
310        md.push_str(&article.summary);
311        md.push_str("\n\n");
312
313        // Main content (already in Markdown)
314        md.push_str(&article.content);
315        md.push_str("\n\n");
316
317        // Linked Decisions section (if any)
318        if !article.linked_decisions.is_empty() {
319            md.push_str("## Related Decisions\n\n");
320            for decision_id in &article.linked_decisions {
321                md.push_str(&format!("- `{}`\n", decision_id));
322            }
323            md.push('\n');
324        }
325
326        // Linked Assets section (if any)
327        if !article.linked_assets.is_empty() {
328            md.push_str("## Linked Assets\n\n");
329            md.push_str("| Asset Type | Asset Name | Relationship |\n");
330            md.push_str("|------------|------------|---------------|\n");
331            for link in &article.linked_assets {
332                let rel_str = link
333                    .relationship
334                    .as_ref()
335                    .map(|r| format!("{:?}", r))
336                    .unwrap_or_else(|| "-".to_string());
337                md.push_str(&format!(
338                    "| {} | {} | {} |\n",
339                    link.asset_type, link.asset_name, rel_str
340                ));
341            }
342            md.push('\n');
343        }
344
345        // Related Articles section (if any)
346        if !article.related_articles.is_empty() {
347            md.push_str("## Related Articles\n\n");
348            for related in &article.related_articles {
349                md.push_str(&format!(
350                    "- **{}**: {} ({})\n",
351                    related.article_number, related.title, related.relationship
352                ));
353            }
354            md.push('\n');
355        }
356
357        // Tags (if any)
358        if !article.tags.is_empty() {
359            let tags_str: Vec<String> = article.tags.iter().map(|t| format!("`{}`", t)).collect();
360            md.push_str(&format!("**Tags:** {}\n\n", tags_str.join(" ")));
361        }
362
363        // Footer with review info
364        md.push_str("---\n\n");
365
366        if !article.reviewers.is_empty() {
367            md.push_str(&format!(
368                "*Reviewers: {}*\n\n",
369                article.reviewers.join(", ")
370            ));
371        }
372
373        if let Some(last_reviewed) = &article.last_reviewed {
374            md.push_str(&format!(
375                "*Last Reviewed: {}*",
376                last_reviewed.format("%Y-%m-%d")
377            ));
378            if let Some(freq) = &article.review_frequency {
379                md.push_str(&format!(" (Review Frequency: {})", freq));
380            }
381            md.push_str("\n\n");
382        }
383
384        md.push_str(&format!(
385            "*Created: {} | Last Updated: {}*\n",
386            article.created_at.format("%Y-%m-%d %H:%M UTC"),
387            article.updated_at.format("%Y-%m-%d %H:%M UTC")
388        ));
389
390        Ok(md)
391    }
392
393    /// Export decisions to a directory as Markdown files
394    ///
395    /// # Arguments
396    ///
397    /// * `decisions` - The decisions to export
398    /// * `dir_path` - Directory to export to (e.g., "decisions/")
399    ///
400    /// # Returns
401    ///
402    /// A Result with the number of files exported, or an ExportError
403    pub fn export_decisions_to_directory(
404        &self,
405        decisions: &[Decision],
406        dir_path: &std::path::Path,
407    ) -> Result<usize, ExportError> {
408        // Create directory if it doesn't exist
409        if !dir_path.exists() {
410            std::fs::create_dir_all(dir_path)
411                .map_err(|e| ExportError::IoError(format!("Failed to create directory: {}", e)))?;
412        }
413
414        let mut count = 0;
415        for decision in decisions {
416            let filename = decision.markdown_filename();
417            let path = dir_path.join(&filename);
418            let md = self.export_decision(decision)?;
419            std::fs::write(&path, md).map_err(|e| {
420                ExportError::IoError(format!("Failed to write {}: {}", filename, e))
421            })?;
422            count += 1;
423        }
424
425        Ok(count)
426    }
427
428    /// Export knowledge articles to a directory as Markdown files
429    ///
430    /// # Arguments
431    ///
432    /// * `articles` - The articles to export
433    /// * `dir_path` - Directory to export to (e.g., "knowledge/")
434    ///
435    /// # Returns
436    ///
437    /// A Result with the number of files exported, or an ExportError
438    pub fn export_knowledge_to_directory(
439        &self,
440        articles: &[KnowledgeArticle],
441        dir_path: &std::path::Path,
442    ) -> Result<usize, ExportError> {
443        // Create directory if it doesn't exist
444        if !dir_path.exists() {
445            std::fs::create_dir_all(dir_path)
446                .map_err(|e| ExportError::IoError(format!("Failed to create directory: {}", e)))?;
447        }
448
449        let mut count = 0;
450        for article in articles {
451            let filename = article.markdown_filename();
452            let path = dir_path.join(&filename);
453            let md = self.export_knowledge(article)?;
454            std::fs::write(&path, md).map_err(|e| {
455                ExportError::IoError(format!("Failed to write {}: {}", filename, e))
456            })?;
457            count += 1;
458        }
459
460        Ok(count)
461    }
462
463    /// Export knowledge articles organized by domain
464    ///
465    /// Creates subdirectories for each domain.
466    ///
467    /// # Arguments
468    ///
469    /// * `articles` - The articles to export
470    /// * `base_dir` - Base directory (e.g., "knowledge/")
471    ///
472    /// # Returns
473    ///
474    /// A Result with the number of files exported, or an ExportError
475    pub fn export_knowledge_by_domain(
476        &self,
477        articles: &[KnowledgeArticle],
478        base_dir: &std::path::Path,
479    ) -> Result<usize, ExportError> {
480        // Create base directory if it doesn't exist
481        if !base_dir.exists() {
482            std::fs::create_dir_all(base_dir)
483                .map_err(|e| ExportError::IoError(format!("Failed to create directory: {}", e)))?;
484        }
485
486        let mut count = 0;
487        for article in articles {
488            // Determine subdirectory based on domain
489            let subdir = if let Some(domain) = &article.domain {
490                base_dir.join(domain)
491            } else {
492                base_dir.join("general")
493            };
494
495            if !subdir.exists() {
496                std::fs::create_dir_all(&subdir).map_err(|e| {
497                    ExportError::IoError(format!("Failed to create directory: {}", e))
498                })?;
499            }
500
501            let filename = article.markdown_filename();
502            let path = subdir.join(&filename);
503            let md = self.export_knowledge(article)?;
504            std::fs::write(&path, md).map_err(|e| {
505                ExportError::IoError(format!("Failed to write {}: {}", filename, e))
506            })?;
507            count += 1;
508        }
509
510        Ok(count)
511    }
512
513    /// Generate a decisions index page in Markdown
514    ///
515    /// Creates a summary page listing all decisions with links.
516    pub fn generate_decisions_index(&self, decisions: &[Decision]) -> String {
517        let mut md = String::new();
518
519        md.push_str("# Architecture Decision Records\n\n");
520        md.push_str("This directory contains all Architecture Decision Records (ADRs) for this project.\n\n");
521
522        // Group by status
523        let accepted: Vec<_> = decisions
524            .iter()
525            .filter(|d| d.status == DecisionStatus::Accepted)
526            .collect();
527        let proposed: Vec<_> = decisions
528            .iter()
529            .filter(|d| d.status == DecisionStatus::Proposed)
530            .collect();
531        let deprecated: Vec<_> = decisions
532            .iter()
533            .filter(|d| d.status == DecisionStatus::Deprecated)
534            .collect();
535        let superseded: Vec<_> = decisions
536            .iter()
537            .filter(|d| d.status == DecisionStatus::Superseded)
538            .collect();
539
540        // Summary table
541        md.push_str("## Summary\n\n");
542        md.push_str(&format!(
543            "| Status | Count |\n|--------|-------|\n| 🟢 Accepted | {} |\n| 🟡 Proposed | {} |\n| 🔴 Deprecated | {} |\n| ⚫ Superseded | {} |\n\n",
544            accepted.len(), proposed.len(), deprecated.len(), superseded.len()
545        ));
546
547        // Decision list
548        md.push_str("## Decisions\n\n");
549        md.push_str("| Number | Title | Status | Category | Date |\n");
550        md.push_str("|--------|-------|--------|----------|------|\n");
551
552        for decision in decisions {
553            let status_icon = match decision.status {
554                DecisionStatus::Draft => "⚪",
555                DecisionStatus::Proposed => "🟡",
556                DecisionStatus::Accepted => "🟢",
557                DecisionStatus::Deprecated => "🔴",
558                DecisionStatus::Superseded => "⚫",
559                DecisionStatus::Rejected => "🔴",
560            };
561            let filename = decision.markdown_filename();
562            md.push_str(&format!(
563                "| [{}]({}) | {} | {} | {} | {} |\n",
564                decision.formatted_number(),
565                filename,
566                decision.title,
567                status_icon,
568                decision.category,
569                decision.date.format("%Y-%m-%d")
570            ));
571        }
572
573        md
574    }
575
576    /// Generate a knowledge index page in Markdown
577    ///
578    /// Creates a summary page listing all articles with links.
579    pub fn generate_knowledge_index(&self, articles: &[KnowledgeArticle]) -> String {
580        let mut md = String::new();
581
582        md.push_str("# Knowledge Base\n\n");
583        md.push_str("This directory contains all Knowledge Base articles for this project.\n\n");
584
585        // Group by domain
586        let mut domains: std::collections::HashMap<String, Vec<&KnowledgeArticle>> =
587            std::collections::HashMap::new();
588        for article in articles {
589            let domain = article
590                .domain
591                .clone()
592                .unwrap_or_else(|| "General".to_string());
593            domains.entry(domain).or_default().push(article);
594        }
595
596        // Articles by domain
597        let mut domain_keys: Vec<_> = domains.keys().collect();
598        domain_keys.sort();
599
600        for domain in domain_keys {
601            let domain_articles = &domains[domain];
602            md.push_str(&format!("## {}\n\n", domain));
603
604            md.push_str("| Number | Title | Type | Status |\n");
605            md.push_str("|--------|-------|------|--------|\n");
606
607            for article in domain_articles.iter() {
608                let type_icon = match article.article_type {
609                    KnowledgeType::Guide => "📖",
610                    KnowledgeType::Standard => "📋",
611                    KnowledgeType::Reference => "📚",
612                    KnowledgeType::HowTo => "🔧",
613                    KnowledgeType::Troubleshooting => "🔍",
614                    KnowledgeType::Policy => "⚖️",
615                    KnowledgeType::Template => "📄",
616                    KnowledgeType::Concept => "💡",
617                    KnowledgeType::Runbook => "📓",
618                    KnowledgeType::Tutorial => "🎓",
619                    KnowledgeType::Glossary => "📝",
620                };
621                let status_icon = match article.status {
622                    KnowledgeStatus::Draft => "🟡",
623                    KnowledgeStatus::Review => "🟠",
624                    KnowledgeStatus::Published => "🟢",
625                    KnowledgeStatus::Archived => "📦",
626                    KnowledgeStatus::Deprecated => "🔴",
627                };
628                let filename = article.markdown_filename();
629                let link_path = if article.domain.is_some() {
630                    format!("{}/{}", domain.to_lowercase(), filename)
631                } else {
632                    format!("general/{}", filename)
633                };
634                md.push_str(&format!(
635                    "| [{}]({}) | {} | {} | {} |\n",
636                    article.formatted_number(),
637                    link_path,
638                    article.title,
639                    type_icon,
640                    status_icon
641                ));
642            }
643
644            md.push('\n');
645        }
646
647        md
648    }
649}
650
651impl Default for MarkdownExporter {
652    fn default() -> Self {
653        Self::new()
654    }
655}
656
657/// Branding configuration for Markdown exports
658#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
659pub struct MarkdownBrandingConfig {
660    /// Logo URL or path (for HTML image tag in markdown)
661    #[serde(skip_serializing_if = "Option::is_none")]
662    pub logo_url: Option<String>,
663
664    /// Logo alt text
665    #[serde(default = "default_logo_alt")]
666    pub logo_alt: String,
667
668    /// Header text (appears at top of document)
669    #[serde(skip_serializing_if = "Option::is_none")]
670    pub header: Option<String>,
671
672    /// Footer text (appears at bottom of document)
673    #[serde(skip_serializing_if = "Option::is_none")]
674    pub footer: Option<String>,
675
676    /// Company or organization name
677    #[serde(skip_serializing_if = "Option::is_none")]
678    pub company_name: Option<String>,
679
680    /// Copyright text
681    #[serde(skip_serializing_if = "Option::is_none")]
682    pub copyright: Option<String>,
683
684    /// Include generation timestamp
685    #[serde(default = "default_true")]
686    pub show_timestamp: bool,
687
688    /// Include table of contents
689    #[serde(default)]
690    pub include_toc: bool,
691
692    /// Custom CSS class for styling (useful for HTML rendering)
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub css_class: Option<String>,
695
696    /// Primary brand color (hex format, e.g., "#0066CC")
697    #[serde(default = "default_brand_color")]
698    pub brand_color: String,
699}
700
701fn default_logo_alt() -> String {
702    "Logo".to_string()
703}
704
705fn default_true() -> bool {
706    true
707}
708
709fn default_brand_color() -> String {
710    "#0066CC".to_string()
711}
712
713/// Branded Markdown exporter with customizable branding
714///
715/// Extends the standard MarkdownExporter with branding options like
716/// logo, header, footer, and company information.
717pub struct BrandedMarkdownExporter {
718    branding: MarkdownBrandingConfig,
719    base_exporter: MarkdownExporter,
720}
721
722impl Default for BrandedMarkdownExporter {
723    fn default() -> Self {
724        Self::new()
725    }
726}
727
728impl BrandedMarkdownExporter {
729    /// Create a new branded Markdown exporter with default branding
730    pub fn new() -> Self {
731        Self {
732            branding: MarkdownBrandingConfig::default(),
733            base_exporter: MarkdownExporter::new(),
734        }
735    }
736
737    /// Create a new branded Markdown exporter with custom branding
738    pub fn with_branding(branding: MarkdownBrandingConfig) -> Self {
739        Self {
740            branding,
741            base_exporter: MarkdownExporter::new(),
742        }
743    }
744
745    /// Update branding configuration
746    pub fn set_branding(&mut self, branding: MarkdownBrandingConfig) {
747        self.branding = branding;
748    }
749
750    /// Get current branding configuration
751    pub fn branding(&self) -> &MarkdownBrandingConfig {
752        &self.branding
753    }
754
755    /// Generate branded header section
756    fn generate_header(&self) -> String {
757        let mut header = String::new();
758
759        // Logo
760        if let Some(logo_url) = &self.branding.logo_url {
761            header.push_str(&format!("![{}]({})\n\n", self.branding.logo_alt, logo_url));
762        }
763
764        // Company name
765        if let Some(company) = &self.branding.company_name {
766            header.push_str(&format!("**{}**\n\n", company));
767        }
768
769        // Header text
770        if let Some(header_text) = &self.branding.header {
771            header.push_str(header_text);
772            header.push_str("\n\n");
773        }
774
775        // Separator
776        if !header.is_empty() {
777            header.push_str("---\n\n");
778        }
779
780        header
781    }
782
783    /// Generate branded footer section
784    fn generate_footer(&self) -> String {
785        use chrono::Utc;
786
787        let mut footer = String::new();
788        footer.push_str("\n---\n\n");
789
790        // Footer text
791        if let Some(footer_text) = &self.branding.footer {
792            footer.push_str(footer_text);
793            footer.push_str("\n\n");
794        }
795
796        // Copyright
797        if let Some(copyright) = &self.branding.copyright {
798            footer.push_str(&format!("*{}*\n\n", copyright));
799        }
800
801        // Timestamp
802        if self.branding.show_timestamp {
803            footer.push_str(&format!(
804                "*Generated: {}*\n",
805                Utc::now().format("%Y-%m-%d %H:%M UTC")
806            ));
807        }
808
809        footer
810    }
811
812    /// Generate table of contents for a decision
813    fn generate_decision_toc(&self, decision: &Decision) -> String {
814        let mut toc = String::new();
815        toc.push_str("## Table of Contents\n\n");
816        toc.push_str("- [Context](#context)\n");
817
818        if !decision.drivers.is_empty() {
819            toc.push_str("- [Decision Drivers](#decision-drivers)\n");
820        }
821        if !decision.options.is_empty() {
822            toc.push_str("- [Considered Options](#considered-options)\n");
823        }
824        toc.push_str("- [Decision](#decision)\n");
825        if decision.consequences.is_some() {
826            toc.push_str("- [Consequences](#consequences)\n");
827        }
828        if !decision.linked_assets.is_empty() {
829            toc.push_str("- [Linked Assets](#linked-assets)\n");
830        }
831        if decision.compliance.is_some() {
832            toc.push_str("- [Compliance Assessment](#compliance-assessment)\n");
833        }
834        toc.push('\n');
835        toc
836    }
837
838    /// Generate table of contents for a knowledge article
839    fn generate_knowledge_toc(&self, article: &KnowledgeArticle) -> String {
840        let mut toc = String::new();
841        toc.push_str("## Table of Contents\n\n");
842        toc.push_str("- [Summary](#summary)\n");
843        toc.push_str("- [Content](#content)\n");
844        if !article.audience.is_empty() {
845            toc.push_str("- [Target Audience](#target-audience)\n");
846        }
847        if !article.related_articles.is_empty() {
848            toc.push_str("- [Related Articles](#related-articles)\n");
849        }
850        toc.push('\n');
851        toc
852    }
853
854    /// Export a decision to branded Markdown format
855    pub fn export_decision(&self, decision: &Decision) -> Result<String, ExportError> {
856        let mut md = String::new();
857
858        // Header with branding
859        md.push_str(&self.generate_header());
860
861        // Table of contents
862        if self.branding.include_toc {
863            md.push_str(&self.generate_decision_toc(decision));
864        }
865
866        // Get base content (without the standard header)
867        let base_content = self.base_exporter.export_decision(decision)?;
868        md.push_str(&base_content);
869
870        // Footer with branding
871        md.push_str(&self.generate_footer());
872
873        Ok(md)
874    }
875
876    /// Export a knowledge article to branded Markdown format
877    pub fn export_knowledge(&self, article: &KnowledgeArticle) -> Result<String, ExportError> {
878        let mut md = String::new();
879
880        // Header with branding
881        md.push_str(&self.generate_header());
882
883        // Table of contents
884        if self.branding.include_toc {
885            md.push_str(&self.generate_knowledge_toc(article));
886        }
887
888        // Get base content
889        let base_content = self.base_exporter.export_knowledge(article)?;
890        md.push_str(&base_content);
891
892        // Footer with branding
893        md.push_str(&self.generate_footer());
894
895        Ok(md)
896    }
897
898    /// Export raw markdown content with branding
899    pub fn export_with_branding(&self, title: &str, content: &str) -> String {
900        let mut md = String::new();
901
902        // Header with branding
903        md.push_str(&self.generate_header());
904
905        // Title and content
906        md.push_str(&format!("# {}\n\n", title));
907        md.push_str(content);
908
909        // Footer with branding
910        md.push_str(&self.generate_footer());
911
912        md
913    }
914
915    /// Generate a branded decisions index
916    pub fn generate_decisions_index(&self, decisions: &[Decision]) -> String {
917        let mut md = String::new();
918
919        // Header with branding
920        md.push_str(&self.generate_header());
921
922        // Get base index
923        let base_index = self.base_exporter.generate_decisions_index(decisions);
924        md.push_str(&base_index);
925
926        // Footer with branding
927        md.push_str(&self.generate_footer());
928
929        md
930    }
931
932    /// Generate a branded knowledge index
933    pub fn generate_knowledge_index(&self, articles: &[KnowledgeArticle]) -> String {
934        let mut md = String::new();
935
936        // Header with branding
937        md.push_str(&self.generate_header());
938
939        // Get base index
940        let base_index = self.base_exporter.generate_knowledge_index(articles);
941        md.push_str(&base_index);
942
943        // Footer with branding
944        md.push_str(&self.generate_footer());
945
946        md
947    }
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use crate::models::decision::{DecisionCategory, DecisionDriver, DecisionOption};
954
955    #[test]
956    fn test_export_decision_markdown() {
957        let decision = Decision::new(
958            1,
959            "Use ODCS Format for Data Contracts",
960            "We need a standard format for defining data contracts across teams.",
961            "Use ODCS v3.1.0 as our data contract format.",
962        )
963        .with_status(DecisionStatus::Accepted)
964        .with_category(DecisionCategory::DataDesign)
965        .with_domain("platform")
966        .add_driver(DecisionDriver::with_priority(
967            "Need standardization",
968            DriverPriority::High,
969        ))
970        .add_option(DecisionOption::with_details(
971            "ODCS",
972            "Open Data Contract Standard",
973            vec!["Industry standard".to_string()],
974            vec!["Learning curve".to_string()],
975            true,
976        ))
977        .with_consequences("All teams must migrate to ODCS format.");
978
979        let exporter = MarkdownExporter::new();
980        let result = exporter.export_decision(&decision);
981        assert!(result.is_ok());
982
983        let md = result.unwrap();
984        assert!(md.contains("# ADR-0001: Use ODCS Format for Data Contracts"));
985        assert!(md.contains("🟢 Accepted"));
986        assert!(md.contains("## Context"));
987        assert!(md.contains("## Decision Drivers"));
988        assert!(md.contains("## Considered Options"));
989        assert!(md.contains("## Decision"));
990        assert!(md.contains("## Consequences"));
991    }
992
993    #[test]
994    fn test_export_knowledge_markdown() {
995        let article = KnowledgeArticle::new(
996            1,
997            "Data Classification Guide",
998            "This guide explains how to classify data.",
999            "## Introduction\n\nData classification is important...",
1000            "data-governance@example.com",
1001        )
1002        .with_status(KnowledgeStatus::Published)
1003        .with_domain("governance");
1004
1005        let exporter = MarkdownExporter::new();
1006        let result = exporter.export_knowledge(&article);
1007        assert!(result.is_ok());
1008
1009        let md = result.unwrap();
1010        assert!(md.contains("# KB-0001: Data Classification Guide"));
1011        assert!(md.contains("🟢 Published"));
1012        assert!(md.contains("## Summary"));
1013        assert!(md.contains("## Introduction"));
1014    }
1015
1016    #[test]
1017    fn test_generate_decisions_index() {
1018        let decisions = vec![
1019            Decision::new(1, "First Decision", "Context", "Decision")
1020                .with_status(DecisionStatus::Accepted),
1021            Decision::new(2, "Second Decision", "Context", "Decision")
1022                .with_status(DecisionStatus::Proposed),
1023        ];
1024
1025        let exporter = MarkdownExporter::new();
1026        let index = exporter.generate_decisions_index(&decisions);
1027
1028        assert!(index.contains("# Architecture Decision Records"));
1029        assert!(index.contains("ADR-0001"));
1030        assert!(index.contains("ADR-0002"));
1031        assert!(index.contains("🟢 Accepted | 1"));
1032        assert!(index.contains("🟡 Proposed | 1"));
1033    }
1034}