Skip to main content

driven/dx_integration/
markdown.rs

1//! DX Markdown Integration
2//!
3//! Provides integration with DX Markdown for generating token-optimized
4//! documentation from Driven types.
5//!
6//! ## Features
7//!
8//! - 73% token reduction compared to standard Markdown
9//! - Three format outputs: LLM, Human, and Machine
10//! - Automatic table rendering for structured data
11
12use crate::parser::UnifiedRule;
13use crate::{DrivenConfig, DrivenError, Result};
14use dx_markdown::{
15    CodeBlockNode, DxmDocument, DxmNode, HeaderNode, InlineNode, ListItem, ListNode, doc_to_human,
16    doc_to_llm, doc_to_machine,
17};
18
19/// Configuration for DX Markdown output
20#[derive(Debug, Clone)]
21pub struct DxMarkdownConfig {
22    /// Include table of contents
23    pub include_toc: bool,
24    /// Include metadata section
25    pub include_metadata: bool,
26    /// Output format preference
27    pub format: DxMarkdownFormat,
28}
29
30impl Default for DxMarkdownConfig {
31    fn default() -> Self {
32        Self {
33            include_toc: true,
34            include_metadata: true,
35            format: DxMarkdownFormat::Llm,
36        }
37    }
38}
39
40/// Output format for DX Markdown
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum DxMarkdownFormat {
43    /// LLM-optimized format (token efficient)
44    Llm,
45    /// Human-readable format (pretty printed)
46    Human,
47    /// Machine format (binary)
48    Machine,
49}
50
51/// Trait for types that can generate DX Markdown documentation
52pub trait DxDocumentable {
53    /// Generate DX Markdown documentation with default config
54    fn to_dx_markdown(&self) -> Result<String>;
55
56    /// Generate DX Markdown documentation with custom config
57    fn to_dx_markdown_with_config(&self, config: &DxMarkdownConfig) -> Result<String>;
58
59    /// Generate DX Markdown as a DxmDocument
60    fn to_dxm_document(&self) -> Result<DxmDocument>;
61}
62
63/// Helper to create a text inline node
64fn text(s: impl Into<String>) -> InlineNode {
65    InlineNode::Text(s.into())
66}
67
68/// Helper to create a paragraph node
69fn paragraph(s: impl Into<String>) -> DxmNode {
70    DxmNode::Paragraph(vec![text(s)])
71}
72
73/// Helper to create a header node
74fn header(level: u8, s: impl Into<String>) -> DxmNode {
75    DxmNode::Header(HeaderNode {
76        level,
77        content: vec![text(s)],
78        priority: None,
79    })
80}
81
82/// Helper to create a list node
83fn list(ordered: bool, items: Vec<String>) -> DxmNode {
84    DxmNode::List(ListNode {
85        ordered,
86        items: items
87            .into_iter()
88            .map(|s| ListItem {
89                content: vec![text(s)],
90                nested: None,
91            })
92            .collect(),
93    })
94}
95
96/// Helper to create a code block node
97fn code_block(language: Option<&str>, code: impl Into<String>) -> DxmNode {
98    DxmNode::CodeBlock(CodeBlockNode {
99        language: language.map(String::from),
100        content: code.into(),
101        priority: None,
102    })
103}
104
105impl DxDocumentable for DrivenConfig {
106    fn to_dx_markdown(&self) -> Result<String> {
107        self.to_dx_markdown_with_config(&DxMarkdownConfig::default())
108    }
109
110    fn to_dx_markdown_with_config(&self, config: &DxMarkdownConfig) -> Result<String> {
111        let doc = self.to_dxm_document()?;
112
113        match config.format {
114            DxMarkdownFormat::Llm => Ok(doc_to_llm(&doc)),
115            DxMarkdownFormat::Human => Ok(doc_to_human(&doc)),
116            DxMarkdownFormat::Machine => {
117                // Return base64 encoded for string representation
118                doc_to_machine_base64(&doc)
119            }
120        }
121    }
122
123    fn to_dxm_document(&self) -> Result<DxmDocument> {
124        let mut doc = DxmDocument::default();
125
126        // Title
127        doc.nodes.push(header(1, "Driven Configuration"));
128
129        // Version info
130        doc.nodes
131            .push(paragraph(format!("Version: {}", self.version)));
132
133        // Default editor
134        doc.nodes.push(header(2, "Default Editor"));
135        doc.nodes
136            .push(paragraph(format!("{}", self.default_editor)));
137
138        // Editor configuration section
139        doc.nodes.push(header(2, "Editor Configuration"));
140
141        let editor_items = vec![
142            format!(
143                "Cursor: {}",
144                if self.editors.cursor {
145                    "enabled"
146                } else {
147                    "disabled"
148                }
149            ),
150            format!(
151                "Copilot: {}",
152                if self.editors.copilot {
153                    "enabled"
154                } else {
155                    "disabled"
156                }
157            ),
158            format!(
159                "Windsurf: {}",
160                if self.editors.windsurf {
161                    "enabled"
162                } else {
163                    "disabled"
164                }
165            ),
166            format!(
167                "Claude: {}",
168                if self.editors.claude {
169                    "enabled"
170                } else {
171                    "disabled"
172                }
173            ),
174            format!(
175                "Aider: {}",
176                if self.editors.aider {
177                    "enabled"
178                } else {
179                    "disabled"
180                }
181            ),
182            format!(
183                "Cline: {}",
184                if self.editors.cline {
185                    "enabled"
186                } else {
187                    "disabled"
188                }
189            ),
190        ];
191        doc.nodes.push(list(false, editor_items));
192
193        // Sync configuration section
194        doc.nodes.push(header(2, "Sync Configuration"));
195
196        let sync_items = vec![
197            format!(
198                "Watch: {}",
199                if self.sync.watch {
200                    "enabled"
201                } else {
202                    "disabled"
203                }
204            ),
205            format!(
206                "Auto Convert: {}",
207                if self.sync.auto_convert {
208                    "enabled"
209                } else {
210                    "disabled"
211                }
212            ),
213            format!("Source of Truth: {}", self.sync.source_of_truth),
214        ];
215        doc.nodes.push(list(false, sync_items));
216
217        Ok(doc)
218    }
219}
220
221impl DxDocumentable for UnifiedRule {
222    fn to_dx_markdown(&self) -> Result<String> {
223        self.to_dx_markdown_with_config(&DxMarkdownConfig::default())
224    }
225
226    fn to_dx_markdown_with_config(&self, config: &DxMarkdownConfig) -> Result<String> {
227        let doc = self.to_dxm_document()?;
228
229        match config.format {
230            DxMarkdownFormat::Llm => Ok(doc_to_llm(&doc)),
231            DxMarkdownFormat::Human => Ok(doc_to_human(&doc)),
232            DxMarkdownFormat::Machine => doc_to_machine_base64(&doc),
233        }
234    }
235
236    fn to_dxm_document(&self) -> Result<DxmDocument> {
237        let mut doc = DxmDocument::default();
238
239        match self {
240            UnifiedRule::Persona {
241                name,
242                role,
243                identity,
244                style,
245                traits,
246                principles,
247            } => {
248                doc.nodes.push(header(1, format!("Persona: {}", name)));
249                doc.nodes.push(paragraph(format!("Role: {}", role)));
250
251                if let Some(id) = identity {
252                    doc.nodes.push(paragraph(format!("Identity: {}", id)));
253                }
254                if let Some(s) = style {
255                    doc.nodes.push(paragraph(format!("Style: {}", s)));
256                }
257
258                if !traits.is_empty() {
259                    doc.nodes.push(header(2, "Traits"));
260                    doc.nodes.push(list(false, traits.clone()));
261                }
262
263                if !principles.is_empty() {
264                    doc.nodes.push(header(2, "Principles"));
265                    doc.nodes.push(list(false, principles.clone()));
266                }
267            }
268            UnifiedRule::Standard {
269                category,
270                priority,
271                description,
272                pattern,
273            } => {
274                doc.nodes
275                    .push(header(1, format!("Standard: {:?}", category)));
276                doc.nodes.push(paragraph(format!("Priority: {}", priority)));
277                doc.nodes.push(paragraph(description.clone()));
278
279                if let Some(p) = pattern {
280                    doc.nodes.push(header(2, "Pattern"));
281                    doc.nodes.push(code_block(None, p.clone()));
282                }
283            }
284            UnifiedRule::Context {
285                includes,
286                excludes,
287                focus,
288            } => {
289                doc.nodes.push(header(1, "Context"));
290
291                if !includes.is_empty() {
292                    doc.nodes.push(header(2, "Include Patterns"));
293                    doc.nodes.push(list(false, includes.clone()));
294                }
295
296                if !excludes.is_empty() {
297                    doc.nodes.push(header(2, "Exclude Patterns"));
298                    doc.nodes.push(list(false, excludes.clone()));
299                }
300
301                if !focus.is_empty() {
302                    doc.nodes.push(header(2, "Focus Areas"));
303                    doc.nodes.push(list(false, focus.clone()));
304                }
305            }
306            UnifiedRule::Workflow { name, steps } => {
307                doc.nodes.push(header(1, format!("Workflow: {}", name)));
308
309                for (i, step) in steps.iter().enumerate() {
310                    doc.nodes
311                        .push(header(2, format!("Step {}: {}", i + 1, step.name)));
312                    doc.nodes.push(paragraph(step.description.clone()));
313
314                    if let Some(cond) = &step.condition {
315                        doc.nodes.push(paragraph(format!("Condition: {}", cond)));
316                    }
317
318                    if !step.actions.is_empty() {
319                        doc.nodes.push(header(3, "Actions"));
320                        doc.nodes.push(list(true, step.actions.clone()));
321                    }
322                }
323            }
324            UnifiedRule::Raw { content } => {
325                doc.nodes.push(header(1, "Raw Content"));
326                doc.nodes.push(code_block(None, content.clone()));
327            }
328        }
329
330        Ok(doc)
331    }
332}
333
334/// Generate documentation for a collection of rules
335pub fn rules_to_dx_markdown(rules: &[UnifiedRule], config: &DxMarkdownConfig) -> Result<String> {
336    let mut doc = DxmDocument::default();
337
338    doc.nodes.push(header(1, "Driven Rules Documentation"));
339    doc.nodes
340        .push(paragraph(format!("Total rules: {}", rules.len())));
341
342    // Group rules by type
343    let mut personas = Vec::new();
344    let mut standards = Vec::new();
345    let mut contexts = Vec::new();
346    let mut workflows = Vec::new();
347
348    for rule in rules {
349        match rule {
350            UnifiedRule::Persona { .. } => personas.push(rule),
351            UnifiedRule::Standard { .. } => standards.push(rule),
352            UnifiedRule::Context { .. } => contexts.push(rule),
353            UnifiedRule::Workflow { .. } => workflows.push(rule),
354            UnifiedRule::Raw { .. } => {} // Skip raw rules in documentation
355        }
356    }
357
358    // Add sections for each type
359    if !personas.is_empty() {
360        doc.nodes
361            .push(header(2, format!("Personas ({})", personas.len())));
362        for rule in personas {
363            let rule_doc = rule.to_dxm_document()?;
364            doc.nodes.extend(rule_doc.nodes);
365        }
366    }
367
368    if !standards.is_empty() {
369        doc.nodes
370            .push(header(2, format!("Standards ({})", standards.len())));
371        for rule in standards {
372            let rule_doc = rule.to_dxm_document()?;
373            doc.nodes.extend(rule_doc.nodes);
374        }
375    }
376
377    if !contexts.is_empty() {
378        doc.nodes
379            .push(header(2, format!("Contexts ({})", contexts.len())));
380        for rule in contexts {
381            let rule_doc = rule.to_dxm_document()?;
382            doc.nodes.extend(rule_doc.nodes);
383        }
384    }
385
386    if !workflows.is_empty() {
387        doc.nodes
388            .push(header(2, format!("Workflows ({})", workflows.len())));
389        for rule in workflows {
390            let rule_doc = rule.to_dxm_document()?;
391            doc.nodes.extend(rule_doc.nodes);
392        }
393    }
394
395    match config.format {
396        DxMarkdownFormat::Llm => Ok(doc_to_llm(&doc)),
397        DxMarkdownFormat::Human => Ok(doc_to_human(&doc)),
398        DxMarkdownFormat::Machine => doc_to_machine_base64(&doc),
399    }
400}
401
402fn doc_to_machine_base64(doc: &DxmDocument) -> Result<String> {
403    let bytes = doc_to_machine(doc).map_err(|e| {
404        DrivenError::Format(format!("DX Markdown machine conversion failed: {}", e))
405    })?;
406    Ok(base64_encode(&bytes))
407}
408
409/// Simple base64 encoding for binary output
410fn base64_encode(data: &[u8]) -> String {
411    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
412
413    let mut result = String::with_capacity((data.len() + 2) / 3 * 4);
414
415    for chunk in data.chunks(3) {
416        let b0 = chunk[0] as usize;
417        let b1 = chunk.get(1).copied().unwrap_or(0) as usize;
418        let b2 = chunk.get(2).copied().unwrap_or(0) as usize;
419
420        result.push(ALPHABET[b0 >> 2] as char);
421        result.push(ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)] as char);
422
423        if chunk.len() > 1 {
424            result.push(ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)] as char);
425        } else {
426            result.push('=');
427        }
428
429        if chunk.len() > 2 {
430            result.push(ALPHABET[b2 & 0x3f] as char);
431        } else {
432            result.push('=');
433        }
434    }
435
436    result
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::format::RuleCategory;
443
444    #[test]
445    fn test_driven_config_to_dx_markdown() {
446        let config = DrivenConfig::default();
447        let md = config.to_dx_markdown().unwrap();
448
449        // The LLM format uses abbreviated syntax
450        assert!(!md.is_empty());
451    }
452
453    #[test]
454    fn test_driven_config_to_dx_markdown_human() {
455        let config = DrivenConfig::default();
456        let md = config
457            .to_dx_markdown_with_config(&DxMarkdownConfig {
458                format: DxMarkdownFormat::Human,
459                ..Default::default()
460            })
461            .unwrap();
462
463        assert!(md.contains("Driven Configuration"));
464        assert!(md.contains("Editor Configuration"));
465    }
466
467    #[test]
468    fn test_unified_rule_persona_to_dx_markdown() {
469        let rule = UnifiedRule::Persona {
470            name: "Architect".to_string(),
471            role: "Senior system architect".to_string(),
472            identity: Some("Expert in distributed systems".to_string()),
473            style: Some("Concise and technical".to_string()),
474            traits: vec!["Analytical".to_string(), "Detail-oriented".to_string()],
475            principles: vec!["SOLID principles".to_string()],
476        };
477
478        let md = rule
479            .to_dx_markdown_with_config(&DxMarkdownConfig {
480                format: DxMarkdownFormat::Human,
481                ..Default::default()
482            })
483            .unwrap();
484        assert!(md.contains("Architect"));
485        assert!(md.contains("Traits"));
486    }
487
488    #[test]
489    fn test_unified_rule_standard_to_dx_markdown() {
490        let rule = UnifiedRule::Standard {
491            category: RuleCategory::Naming,
492            priority: 1,
493            description: "Use snake_case for functions".to_string(),
494            pattern: Some("fn my_function()".to_string()),
495        };
496
497        let md = rule
498            .to_dx_markdown_with_config(&DxMarkdownConfig {
499                format: DxMarkdownFormat::Human,
500                ..Default::default()
501            })
502            .unwrap();
503        assert!(md.contains("Naming"));
504        assert!(md.contains("snake_case"));
505    }
506
507    #[test]
508    fn test_rules_to_dx_markdown() {
509        let rules = vec![
510            UnifiedRule::Persona {
511                name: "Dev".to_string(),
512                role: "Developer".to_string(),
513                identity: None,
514                style: None,
515                traits: vec![],
516                principles: vec![],
517            },
518            UnifiedRule::Standard {
519                category: RuleCategory::Style,
520                priority: 1,
521                description: "Use 4 spaces".to_string(),
522                pattern: None,
523            },
524        ];
525
526        let config = DxMarkdownConfig {
527            format: DxMarkdownFormat::Human,
528            ..Default::default()
529        };
530        let md = rules_to_dx_markdown(&rules, &config).unwrap();
531
532        assert!(md.contains("Driven Rules Documentation"));
533        assert!(md.contains("Personas"));
534        assert!(md.contains("Standards"));
535    }
536
537    #[test]
538    fn test_base64_encode() {
539        assert_eq!(base64_encode(b""), "");
540        assert_eq!(base64_encode(b"f"), "Zg==");
541        assert_eq!(base64_encode(b"fo"), "Zm8=");
542        assert_eq!(base64_encode(b"foo"), "Zm9v");
543        assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
544    }
545}