1use 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#[derive(Debug, Clone)]
21pub struct DxMarkdownConfig {
22 pub include_toc: bool,
24 pub include_metadata: bool,
26 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum DxMarkdownFormat {
43 Llm,
45 Human,
47 Machine,
49}
50
51pub trait DxDocumentable {
53 fn to_dx_markdown(&self) -> Result<String>;
55
56 fn to_dx_markdown_with_config(&self, config: &DxMarkdownConfig) -> Result<String>;
58
59 fn to_dxm_document(&self) -> Result<DxmDocument>;
61}
62
63fn text(s: impl Into<String>) -> InlineNode {
65 InlineNode::Text(s.into())
66}
67
68fn paragraph(s: impl Into<String>) -> DxmNode {
70 DxmNode::Paragraph(vec![text(s)])
71}
72
73fn 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
82fn 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
96fn 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 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 doc.nodes.push(header(1, "Driven Configuration"));
128
129 doc.nodes
131 .push(paragraph(format!("Version: {}", self.version)));
132
133 doc.nodes.push(header(2, "Default Editor"));
135 doc.nodes
136 .push(paragraph(format!("{}", self.default_editor)));
137
138 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 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
334pub 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 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 { .. } => {} }
356 }
357
358 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
409fn 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 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}