data_modelling_sdk/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::Proposed => "🟡 Proposed",
34            DecisionStatus::Accepted => "🟢 Accepted",
35            DecisionStatus::Deprecated => "🔴 Deprecated",
36            DecisionStatus::Superseded => "⚫ Superseded",
37        };
38
39        md.push_str(&format!(
40            "# ADR-{:04}: {}\n\n",
41            decision.number, decision.title
42        ));
43
44        // Metadata table
45        md.push_str("| Property | Value |\n");
46        md.push_str("|----------|-------|\n");
47        md.push_str(&format!("| **Status** | {} |\n", status_badge));
48        md.push_str(&format!("| **Category** | {} |\n", decision.category));
49        if let Some(domain) = &decision.domain {
50            md.push_str(&format!("| **Domain** | {} |\n", domain));
51        }
52        md.push_str(&format!(
53            "| **Date** | {} |\n",
54            decision.date.format("%Y-%m-%d")
55        ));
56        if !decision.deciders.is_empty() {
57            md.push_str(&format!(
58                "| **Deciders** | {} |\n",
59                decision.deciders.join(", ")
60            ));
61        }
62        md.push('\n');
63
64        // Context section
65        md.push_str("## Context\n\n");
66        md.push_str(&decision.context);
67        md.push_str("\n\n");
68
69        // Decision Drivers section (if any)
70        if !decision.drivers.is_empty() {
71            md.push_str("## Decision Drivers\n\n");
72            for driver in &decision.drivers {
73                let priority_str = match &driver.priority {
74                    Some(DriverPriority::High) => " *(High Priority)*",
75                    Some(DriverPriority::Medium) => " *(Medium Priority)*",
76                    Some(DriverPriority::Low) => " *(Low Priority)*",
77                    None => "",
78                };
79                md.push_str(&format!("- {}{}\n", driver.description, priority_str));
80            }
81            md.push('\n');
82        }
83
84        // Considered Options section (if any)
85        if !decision.options.is_empty() {
86            md.push_str("## Considered Options\n\n");
87            for option in &decision.options {
88                let selected_marker = if option.selected { " ✓" } else { "" };
89                md.push_str(&format!("### {}{}\n\n", option.name, selected_marker));
90
91                if let Some(desc) = &option.description {
92                    md.push_str(desc);
93                    md.push_str("\n\n");
94                }
95
96                if !option.pros.is_empty() {
97                    md.push_str("**Pros:**\n");
98                    for pro in &option.pros {
99                        md.push_str(&format!("- ✅ {}\n", pro));
100                    }
101                    md.push('\n');
102                }
103
104                if !option.cons.is_empty() {
105                    md.push_str("**Cons:**\n");
106                    for con in &option.cons {
107                        md.push_str(&format!("- ❌ {}\n", con));
108                    }
109                    md.push('\n');
110                }
111            }
112        }
113
114        // Decision section
115        md.push_str("## Decision\n\n");
116        md.push_str(&decision.decision);
117        md.push_str("\n\n");
118
119        // Consequences section (if any)
120        if let Some(consequences) = &decision.consequences {
121            md.push_str("## Consequences\n\n");
122            md.push_str(consequences);
123            md.push_str("\n\n");
124        }
125
126        // Linked Assets section (if any)
127        if !decision.linked_assets.is_empty() {
128            md.push_str("## Linked Assets\n\n");
129            md.push_str("| Asset Type | Asset Name | Relationship |\n");
130            md.push_str("|------------|------------|---------------|\n");
131            for link in &decision.linked_assets {
132                let rel_str = link
133                    .relationship
134                    .as_ref()
135                    .map(|r| format!("{:?}", r))
136                    .unwrap_or_else(|| "-".to_string());
137                md.push_str(&format!(
138                    "| {} | {} | {} |\n",
139                    link.asset_type, link.asset_name, rel_str
140                ));
141            }
142            md.push('\n');
143        }
144
145        // Supersession info
146        if let Some(supersedes) = &decision.supersedes {
147            md.push_str(&format!(
148                "> **Note:** This decision supersedes `{}`\n\n",
149                supersedes
150            ));
151        }
152        if let Some(superseded_by) = &decision.superseded_by {
153            md.push_str(&format!(
154                "> **Warning:** This decision has been superseded by `{}`\n\n",
155                superseded_by
156            ));
157        }
158
159        // Compliance section (if any)
160        if let Some(compliance) = &decision.compliance {
161            md.push_str("## Compliance Assessment\n\n");
162            if let Some(reg) = &compliance.regulatory_impact {
163                md.push_str(&format!("**Regulatory Impact:** {}\n\n", reg));
164            }
165            if let Some(priv_assess) = &compliance.privacy_assessment {
166                md.push_str(&format!("**Privacy Assessment:** {}\n\n", priv_assess));
167            }
168            if let Some(sec_assess) = &compliance.security_assessment {
169                md.push_str(&format!("**Security Assessment:** {}\n\n", sec_assess));
170            }
171            if !compliance.frameworks.is_empty() {
172                md.push_str(&format!(
173                    "**Frameworks:** {}\n\n",
174                    compliance.frameworks.join(", ")
175                ));
176            }
177        }
178
179        // Tags (if any)
180        if !decision.tags.is_empty() {
181            let tags_str: Vec<String> = decision.tags.iter().map(|t| format!("`{}`", t)).collect();
182            md.push_str(&format!("**Tags:** {}\n\n", tags_str.join(" ")));
183        }
184
185        // Footer with timestamps
186        md.push_str("---\n\n");
187        md.push_str(&format!(
188            "*Created: {} | Last Updated: {}*\n",
189            decision.created_at.format("%Y-%m-%d %H:%M UTC"),
190            decision.updated_at.format("%Y-%m-%d %H:%M UTC")
191        ));
192
193        if let Some(conf_date) = &decision.confirmation_date {
194            md.push_str(&format!(
195                "\n*Last Confirmed: {}*",
196                conf_date.format("%Y-%m-%d")
197            ));
198            if let Some(notes) = &decision.confirmation_notes {
199                md.push_str(&format!(" - {}", notes));
200            }
201            md.push('\n');
202        }
203
204        Ok(md)
205    }
206
207    /// Export a knowledge article to Markdown format
208    ///
209    /// # Arguments
210    ///
211    /// * `article` - The KnowledgeArticle to export
212    ///
213    /// # Returns
214    ///
215    /// A Markdown string
216    pub fn export_knowledge(&self, article: &KnowledgeArticle) -> Result<String, ExportError> {
217        let mut md = String::new();
218
219        // Title with type badge
220        let type_badge = match article.article_type {
221            KnowledgeType::Guide => "📖 Guide",
222            KnowledgeType::Standard => "📋 Standard",
223            KnowledgeType::Reference => "📚 Reference",
224            KnowledgeType::Glossary => "📝 Glossary",
225            KnowledgeType::HowTo => "🔧 How-To",
226            KnowledgeType::Troubleshooting => "🔍 Troubleshooting",
227            KnowledgeType::Policy => "⚖️ Policy",
228            KnowledgeType::Template => "📄 Template",
229        };
230
231        let status_badge = match article.status {
232            KnowledgeStatus::Draft => "🟡 Draft",
233            KnowledgeStatus::Published => "🟢 Published",
234            KnowledgeStatus::Archived => "📦 Archived",
235            KnowledgeStatus::Deprecated => "🔴 Deprecated",
236        };
237
238        md.push_str(&format!("# {}: {}\n\n", article.number, article.title));
239
240        // Metadata table
241        md.push_str("| Property | Value |\n");
242        md.push_str("|----------|-------|\n");
243        md.push_str(&format!("| **Type** | {} |\n", type_badge));
244        md.push_str(&format!("| **Status** | {} |\n", status_badge));
245        if let Some(domain) = &article.domain {
246            md.push_str(&format!("| **Domain** | {} |\n", domain));
247        }
248        md.push_str(&format!("| **Author** | {} |\n", article.author));
249        if let Some(skill) = &article.skill_level {
250            md.push_str(&format!("| **Skill Level** | {} |\n", skill));
251        }
252        if !article.audience.is_empty() {
253            md.push_str(&format!(
254                "| **Audience** | {} |\n",
255                article.audience.join(", ")
256            ));
257        }
258        md.push('\n');
259
260        // Summary section
261        md.push_str("## Summary\n\n");
262        md.push_str(&article.summary);
263        md.push_str("\n\n");
264
265        // Main content (already in Markdown)
266        md.push_str(&article.content);
267        md.push_str("\n\n");
268
269        // Linked Decisions section (if any)
270        if !article.linked_decisions.is_empty() {
271            md.push_str("## Related Decisions\n\n");
272            for decision_id in &article.linked_decisions {
273                md.push_str(&format!("- `{}`\n", decision_id));
274            }
275            md.push('\n');
276        }
277
278        // Linked Assets section (if any)
279        if !article.linked_assets.is_empty() {
280            md.push_str("## Linked Assets\n\n");
281            md.push_str("| Asset Type | Asset Name | Relationship |\n");
282            md.push_str("|------------|------------|---------------|\n");
283            for link in &article.linked_assets {
284                let rel_str = link
285                    .relationship
286                    .as_ref()
287                    .map(|r| format!("{:?}", r))
288                    .unwrap_or_else(|| "-".to_string());
289                md.push_str(&format!(
290                    "| {} | {} | {} |\n",
291                    link.asset_type, link.asset_name, rel_str
292                ));
293            }
294            md.push('\n');
295        }
296
297        // Related Articles section (if any)
298        if !article.related_articles.is_empty() {
299            md.push_str("## Related Articles\n\n");
300            for related in &article.related_articles {
301                md.push_str(&format!(
302                    "- **{}**: {} ({})\n",
303                    related.article_number, related.title, related.relationship
304                ));
305            }
306            md.push('\n');
307        }
308
309        // Tags (if any)
310        if !article.tags.is_empty() {
311            let tags_str: Vec<String> = article.tags.iter().map(|t| format!("`{}`", t)).collect();
312            md.push_str(&format!("**Tags:** {}\n\n", tags_str.join(" ")));
313        }
314
315        // Footer with review info
316        md.push_str("---\n\n");
317
318        if !article.reviewers.is_empty() {
319            md.push_str(&format!(
320                "*Reviewers: {}*\n\n",
321                article.reviewers.join(", ")
322            ));
323        }
324
325        if let Some(last_reviewed) = &article.last_reviewed {
326            md.push_str(&format!(
327                "*Last Reviewed: {}*",
328                last_reviewed.format("%Y-%m-%d")
329            ));
330            if let Some(freq) = &article.review_frequency {
331                md.push_str(&format!(" (Review Frequency: {})", freq));
332            }
333            md.push_str("\n\n");
334        }
335
336        md.push_str(&format!(
337            "*Created: {} | Last Updated: {}*\n",
338            article.created_at.format("%Y-%m-%d %H:%M UTC"),
339            article.updated_at.format("%Y-%m-%d %H:%M UTC")
340        ));
341
342        Ok(md)
343    }
344
345    /// Export decisions to a directory as Markdown files
346    ///
347    /// # Arguments
348    ///
349    /// * `decisions` - The decisions to export
350    /// * `dir_path` - Directory to export to (e.g., "decisions/")
351    ///
352    /// # Returns
353    ///
354    /// A Result with the number of files exported, or an ExportError
355    pub fn export_decisions_to_directory(
356        &self,
357        decisions: &[Decision],
358        dir_path: &std::path::Path,
359    ) -> Result<usize, ExportError> {
360        // Create directory if it doesn't exist
361        if !dir_path.exists() {
362            std::fs::create_dir_all(dir_path)
363                .map_err(|e| ExportError::IoError(format!("Failed to create directory: {}", e)))?;
364        }
365
366        let mut count = 0;
367        for decision in decisions {
368            let filename = decision.markdown_filename();
369            let path = dir_path.join(&filename);
370            let md = self.export_decision(decision)?;
371            std::fs::write(&path, md).map_err(|e| {
372                ExportError::IoError(format!("Failed to write {}: {}", filename, e))
373            })?;
374            count += 1;
375        }
376
377        Ok(count)
378    }
379
380    /// Export knowledge articles to a directory as Markdown files
381    ///
382    /// # Arguments
383    ///
384    /// * `articles` - The articles to export
385    /// * `dir_path` - Directory to export to (e.g., "knowledge/")
386    ///
387    /// # Returns
388    ///
389    /// A Result with the number of files exported, or an ExportError
390    pub fn export_knowledge_to_directory(
391        &self,
392        articles: &[KnowledgeArticle],
393        dir_path: &std::path::Path,
394    ) -> Result<usize, ExportError> {
395        // Create directory if it doesn't exist
396        if !dir_path.exists() {
397            std::fs::create_dir_all(dir_path)
398                .map_err(|e| ExportError::IoError(format!("Failed to create directory: {}", e)))?;
399        }
400
401        let mut count = 0;
402        for article in articles {
403            let filename = article.markdown_filename();
404            let path = dir_path.join(&filename);
405            let md = self.export_knowledge(article)?;
406            std::fs::write(&path, md).map_err(|e| {
407                ExportError::IoError(format!("Failed to write {}: {}", filename, e))
408            })?;
409            count += 1;
410        }
411
412        Ok(count)
413    }
414
415    /// Export knowledge articles organized by domain
416    ///
417    /// Creates subdirectories for each domain.
418    ///
419    /// # Arguments
420    ///
421    /// * `articles` - The articles to export
422    /// * `base_dir` - Base directory (e.g., "knowledge/")
423    ///
424    /// # Returns
425    ///
426    /// A Result with the number of files exported, or an ExportError
427    pub fn export_knowledge_by_domain(
428        &self,
429        articles: &[KnowledgeArticle],
430        base_dir: &std::path::Path,
431    ) -> Result<usize, ExportError> {
432        // Create base directory if it doesn't exist
433        if !base_dir.exists() {
434            std::fs::create_dir_all(base_dir)
435                .map_err(|e| ExportError::IoError(format!("Failed to create directory: {}", e)))?;
436        }
437
438        let mut count = 0;
439        for article in articles {
440            // Determine subdirectory based on domain
441            let subdir = if let Some(domain) = &article.domain {
442                base_dir.join(domain)
443            } else {
444                base_dir.join("general")
445            };
446
447            if !subdir.exists() {
448                std::fs::create_dir_all(&subdir).map_err(|e| {
449                    ExportError::IoError(format!("Failed to create directory: {}", e))
450                })?;
451            }
452
453            let filename = article.markdown_filename();
454            let path = subdir.join(&filename);
455            let md = self.export_knowledge(article)?;
456            std::fs::write(&path, md).map_err(|e| {
457                ExportError::IoError(format!("Failed to write {}: {}", filename, e))
458            })?;
459            count += 1;
460        }
461
462        Ok(count)
463    }
464
465    /// Generate a decisions index page in Markdown
466    ///
467    /// Creates a summary page listing all decisions with links.
468    pub fn generate_decisions_index(&self, decisions: &[Decision]) -> String {
469        let mut md = String::new();
470
471        md.push_str("# Architecture Decision Records\n\n");
472        md.push_str("This directory contains all Architecture Decision Records (ADRs) for this project.\n\n");
473
474        // Group by status
475        let accepted: Vec<_> = decisions
476            .iter()
477            .filter(|d| d.status == DecisionStatus::Accepted)
478            .collect();
479        let proposed: Vec<_> = decisions
480            .iter()
481            .filter(|d| d.status == DecisionStatus::Proposed)
482            .collect();
483        let deprecated: Vec<_> = decisions
484            .iter()
485            .filter(|d| d.status == DecisionStatus::Deprecated)
486            .collect();
487        let superseded: Vec<_> = decisions
488            .iter()
489            .filter(|d| d.status == DecisionStatus::Superseded)
490            .collect();
491
492        // Summary table
493        md.push_str("## Summary\n\n");
494        md.push_str(&format!(
495            "| Status | Count |\n|--------|-------|\n| 🟢 Accepted | {} |\n| 🟡 Proposed | {} |\n| 🔴 Deprecated | {} |\n| ⚫ Superseded | {} |\n\n",
496            accepted.len(), proposed.len(), deprecated.len(), superseded.len()
497        ));
498
499        // Decision list
500        md.push_str("## Decisions\n\n");
501        md.push_str("| Number | Title | Status | Category | Date |\n");
502        md.push_str("|--------|-------|--------|----------|------|\n");
503
504        for decision in decisions {
505            let status_icon = match decision.status {
506                DecisionStatus::Proposed => "🟡",
507                DecisionStatus::Accepted => "🟢",
508                DecisionStatus::Deprecated => "🔴",
509                DecisionStatus::Superseded => "⚫",
510            };
511            let filename = decision.markdown_filename();
512            md.push_str(&format!(
513                "| [ADR-{:04}]({}) | {} | {} | {} | {} |\n",
514                decision.number,
515                filename,
516                decision.title,
517                status_icon,
518                decision.category,
519                decision.date.format("%Y-%m-%d")
520            ));
521        }
522
523        md
524    }
525
526    /// Generate a knowledge index page in Markdown
527    ///
528    /// Creates a summary page listing all articles with links.
529    pub fn generate_knowledge_index(&self, articles: &[KnowledgeArticle]) -> String {
530        let mut md = String::new();
531
532        md.push_str("# Knowledge Base\n\n");
533        md.push_str("This directory contains all Knowledge Base articles for this project.\n\n");
534
535        // Group by domain
536        let mut domains: std::collections::HashMap<String, Vec<&KnowledgeArticle>> =
537            std::collections::HashMap::new();
538        for article in articles {
539            let domain = article
540                .domain
541                .clone()
542                .unwrap_or_else(|| "General".to_string());
543            domains.entry(domain).or_default().push(article);
544        }
545
546        // Articles by domain
547        let mut domain_keys: Vec<_> = domains.keys().collect();
548        domain_keys.sort();
549
550        for domain in domain_keys {
551            let domain_articles = &domains[domain];
552            md.push_str(&format!("## {}\n\n", domain));
553
554            md.push_str("| Number | Title | Type | Status |\n");
555            md.push_str("|--------|-------|------|--------|\n");
556
557            for article in domain_articles.iter() {
558                let type_icon = match article.article_type {
559                    KnowledgeType::Guide => "📖",
560                    KnowledgeType::Standard => "📋",
561                    KnowledgeType::Reference => "📚",
562                    KnowledgeType::Glossary => "📝",
563                    KnowledgeType::HowTo => "🔧",
564                    KnowledgeType::Troubleshooting => "🔍",
565                    KnowledgeType::Policy => "⚖️",
566                    KnowledgeType::Template => "📄",
567                };
568                let status_icon = match article.status {
569                    KnowledgeStatus::Draft => "🟡",
570                    KnowledgeStatus::Published => "🟢",
571                    KnowledgeStatus::Archived => "📦",
572                    KnowledgeStatus::Deprecated => "🔴",
573                };
574                let filename = article.markdown_filename();
575                let link_path = if article.domain.is_some() {
576                    format!("{}/{}", domain.to_lowercase(), filename)
577                } else {
578                    format!("general/{}", filename)
579                };
580                md.push_str(&format!(
581                    "| [{}]({}) | {} | {} | {} |\n",
582                    article.number, link_path, article.title, type_icon, status_icon
583                ));
584            }
585
586            md.push('\n');
587        }
588
589        md
590    }
591}
592
593impl Default for MarkdownExporter {
594    fn default() -> Self {
595        Self::new()
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use crate::models::decision::{DecisionCategory, DecisionDriver, DecisionOption};
603
604    #[test]
605    fn test_export_decision_markdown() {
606        let decision = Decision::new(
607            1,
608            "Use ODCS Format for Data Contracts",
609            "We need a standard format for defining data contracts across teams.",
610            "Use ODCS v3.1.0 as our data contract format.",
611        )
612        .with_status(DecisionStatus::Accepted)
613        .with_category(DecisionCategory::DataDesign)
614        .with_domain("platform")
615        .add_driver(DecisionDriver::with_priority(
616            "Need standardization",
617            DriverPriority::High,
618        ))
619        .add_option(DecisionOption::with_details(
620            "ODCS",
621            "Open Data Contract Standard",
622            vec!["Industry standard".to_string()],
623            vec!["Learning curve".to_string()],
624            true,
625        ))
626        .with_consequences("All teams must migrate to ODCS format.");
627
628        let exporter = MarkdownExporter::new();
629        let result = exporter.export_decision(&decision);
630        assert!(result.is_ok());
631
632        let md = result.unwrap();
633        assert!(md.contains("# ADR-0001: Use ODCS Format for Data Contracts"));
634        assert!(md.contains("🟢 Accepted"));
635        assert!(md.contains("## Context"));
636        assert!(md.contains("## Decision Drivers"));
637        assert!(md.contains("## Considered Options"));
638        assert!(md.contains("## Decision"));
639        assert!(md.contains("## Consequences"));
640    }
641
642    #[test]
643    fn test_export_knowledge_markdown() {
644        let article = KnowledgeArticle::new(
645            1,
646            "Data Classification Guide",
647            "This guide explains how to classify data.",
648            "## Introduction\n\nData classification is important...",
649            "data-governance@example.com",
650        )
651        .with_status(KnowledgeStatus::Published)
652        .with_domain("governance");
653
654        let exporter = MarkdownExporter::new();
655        let result = exporter.export_knowledge(&article);
656        assert!(result.is_ok());
657
658        let md = result.unwrap();
659        assert!(md.contains("# KB-0001: Data Classification Guide"));
660        assert!(md.contains("🟢 Published"));
661        assert!(md.contains("## Summary"));
662        assert!(md.contains("## Introduction"));
663    }
664
665    #[test]
666    fn test_generate_decisions_index() {
667        let decisions = vec![
668            Decision::new(1, "First Decision", "Context", "Decision")
669                .with_status(DecisionStatus::Accepted),
670            Decision::new(2, "Second Decision", "Context", "Decision")
671                .with_status(DecisionStatus::Proposed),
672        ];
673
674        let exporter = MarkdownExporter::new();
675        let index = exporter.generate_decisions_index(&decisions);
676
677        assert!(index.contains("# Architecture Decision Records"));
678        assert!(index.contains("ADR-0001"));
679        assert!(index.contains("ADR-0002"));
680        assert!(index.contains("🟢 Accepted | 1"));
681        assert!(index.contains("🟡 Proposed | 1"));
682    }
683}