1use crate::export::ExportError;
7use crate::models::decision::{Decision, DecisionStatus, DriverPriority};
8use crate::models::knowledge::{KnowledgeArticle, KnowledgeStatus, KnowledgeType};
9
10pub struct MarkdownExporter;
12
13impl MarkdownExporter {
14 pub fn new() -> Self {
16 Self
17 }
18
19 pub fn export_decision(&self, decision: &Decision) -> Result<String, ExportError> {
29 let mut md = String::new();
30
31 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 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 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 md.push_str("## Context\n\n");
101 md.push_str(&decision.context);
102 md.push_str("\n\n");
103
104 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 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 md.push_str("## Decision\n\n");
151 md.push_str(&decision.decision);
152 md.push_str("\n\n");
153
154 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 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 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 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 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 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 pub fn export_knowledge(&self, article: &KnowledgeArticle) -> Result<String, ExportError> {
252 let mut md = String::new();
253
254 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 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 md.push_str("## Summary\n\n");
310 md.push_str(&article.summary);
311 md.push_str("\n\n");
312
313 md.push_str(&article.content);
315 md.push_str("\n\n");
316
317 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 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 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 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 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 pub fn export_decisions_to_directory(
404 &self,
405 decisions: &[Decision],
406 dir_path: &std::path::Path,
407 ) -> Result<usize, ExportError> {
408 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 pub fn export_knowledge_to_directory(
439 &self,
440 articles: &[KnowledgeArticle],
441 dir_path: &std::path::Path,
442 ) -> Result<usize, ExportError> {
443 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 pub fn export_knowledge_by_domain(
476 &self,
477 articles: &[KnowledgeArticle],
478 base_dir: &std::path::Path,
479 ) -> Result<usize, ExportError> {
480 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
659pub struct MarkdownBrandingConfig {
660 #[serde(skip_serializing_if = "Option::is_none")]
662 pub logo_url: Option<String>,
663
664 #[serde(default = "default_logo_alt")]
666 pub logo_alt: String,
667
668 #[serde(skip_serializing_if = "Option::is_none")]
670 pub header: Option<String>,
671
672 #[serde(skip_serializing_if = "Option::is_none")]
674 pub footer: Option<String>,
675
676 #[serde(skip_serializing_if = "Option::is_none")]
678 pub company_name: Option<String>,
679
680 #[serde(skip_serializing_if = "Option::is_none")]
682 pub copyright: Option<String>,
683
684 #[serde(default = "default_true")]
686 pub show_timestamp: bool,
687
688 #[serde(default)]
690 pub include_toc: bool,
691
692 #[serde(skip_serializing_if = "Option::is_none")]
694 pub css_class: Option<String>,
695
696 #[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
713pub 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 pub fn new() -> Self {
731 Self {
732 branding: MarkdownBrandingConfig::default(),
733 base_exporter: MarkdownExporter::new(),
734 }
735 }
736
737 pub fn with_branding(branding: MarkdownBrandingConfig) -> Self {
739 Self {
740 branding,
741 base_exporter: MarkdownExporter::new(),
742 }
743 }
744
745 pub fn set_branding(&mut self, branding: MarkdownBrandingConfig) {
747 self.branding = branding;
748 }
749
750 pub fn branding(&self) -> &MarkdownBrandingConfig {
752 &self.branding
753 }
754
755 fn generate_header(&self) -> String {
757 let mut header = String::new();
758
759 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 if let Some(company) = &self.branding.company_name {
766 header.push_str(&format!("**{}**\n\n", company));
767 }
768
769 if let Some(header_text) = &self.branding.header {
771 header.push_str(header_text);
772 header.push_str("\n\n");
773 }
774
775 if !header.is_empty() {
777 header.push_str("---\n\n");
778 }
779
780 header
781 }
782
783 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 if let Some(footer_text) = &self.branding.footer {
792 footer.push_str(footer_text);
793 footer.push_str("\n\n");
794 }
795
796 if let Some(copyright) = &self.branding.copyright {
798 footer.push_str(&format!("*{}*\n\n", copyright));
799 }
800
801 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 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 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 pub fn export_decision(&self, decision: &Decision) -> Result<String, ExportError> {
856 let mut md = String::new();
857
858 md.push_str(&self.generate_header());
860
861 if self.branding.include_toc {
863 md.push_str(&self.generate_decision_toc(decision));
864 }
865
866 let base_content = self.base_exporter.export_decision(decision)?;
868 md.push_str(&base_content);
869
870 md.push_str(&self.generate_footer());
872
873 Ok(md)
874 }
875
876 pub fn export_knowledge(&self, article: &KnowledgeArticle) -> Result<String, ExportError> {
878 let mut md = String::new();
879
880 md.push_str(&self.generate_header());
882
883 if self.branding.include_toc {
885 md.push_str(&self.generate_knowledge_toc(article));
886 }
887
888 let base_content = self.base_exporter.export_knowledge(article)?;
890 md.push_str(&base_content);
891
892 md.push_str(&self.generate_footer());
894
895 Ok(md)
896 }
897
898 pub fn export_with_branding(&self, title: &str, content: &str) -> String {
900 let mut md = String::new();
901
902 md.push_str(&self.generate_header());
904
905 md.push_str(&format!("# {}\n\n", title));
907 md.push_str(content);
908
909 md.push_str(&self.generate_footer());
911
912 md
913 }
914
915 pub fn generate_decisions_index(&self, decisions: &[Decision]) -> String {
917 let mut md = String::new();
918
919 md.push_str(&self.generate_header());
921
922 let base_index = self.base_exporter.generate_decisions_index(decisions);
924 md.push_str(&base_index);
925
926 md.push_str(&self.generate_footer());
928
929 md
930 }
931
932 pub fn generate_knowledge_index(&self, articles: &[KnowledgeArticle]) -> String {
934 let mut md = String::new();
935
936 md.push_str(&self.generate_header());
938
939 let base_index = self.base_exporter.generate_knowledge_index(articles);
941 md.push_str(&base_index);
942
943 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}