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::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 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 md.push_str("## Context\n\n");
66 md.push_str(&decision.context);
67 md.push_str("\n\n");
68
69 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 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 md.push_str("## Decision\n\n");
116 md.push_str(&decision.decision);
117 md.push_str("\n\n");
118
119 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 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 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 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 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 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 pub fn export_knowledge(&self, article: &KnowledgeArticle) -> Result<String, ExportError> {
217 let mut md = String::new();
218
219 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 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 md.push_str("## Summary\n\n");
262 md.push_str(&article.summary);
263 md.push_str("\n\n");
264
265 md.push_str(&article.content);
267 md.push_str("\n\n");
268
269 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 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 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 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 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 pub fn export_decisions_to_directory(
356 &self,
357 decisions: &[Decision],
358 dir_path: &std::path::Path,
359 ) -> Result<usize, ExportError> {
360 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 pub fn export_knowledge_to_directory(
391 &self,
392 articles: &[KnowledgeArticle],
393 dir_path: &std::path::Path,
394 ) -> Result<usize, ExportError> {
395 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 pub fn export_knowledge_by_domain(
428 &self,
429 articles: &[KnowledgeArticle],
430 base_dir: &std::path::Path,
431 ) -> Result<usize, ExportError> {
432 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 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 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 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 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 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 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 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 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}