Skip to main content

driven/dx_integration/
mod.rs

1//! DX Ecosystem Integration
2//!
3//! This module provides integration with the DX Serializer and DX Markdown
4//! for consistent, high-performance serialization and documentation across
5//! all Driven configuration types.
6//!
7//! ## Formats
8//!
9//! - **DX LLM Format**: Human and LLM-readable text format (26.8% more efficient than TOON)
10//! - **DX Machine Format**: Binary format for runtime (0.70ns field access)
11//! - **DX Markdown**: Token-optimized documentation format (73% token reduction)
12//!
13//! ## Usage
14//!
15//! ```rust,ignore
16//! use driven::dx_integration::{DxSerializable, DxDocumentable};
17//! use driven::DrivenConfig;
18//!
19//! let config = DrivenConfig::default();
20//!
21//! // Serialize to DX LLM format
22//! let llm_text = config.to_dx_llm()?;
23//!
24//! // Deserialize from DX LLM format
25//! let loaded: DrivenConfig = DrivenConfig::from_dx_llm(&llm_text)?;
26//!
27//! // Generate documentation in DX Markdown format
28//! let doc = config.to_dx_markdown()?;
29//! ```
30
31pub mod legacy;
32mod markdown;
33
34use crate::{DrivenConfig, DrivenError, EditorConfig, Result};
35use serializer::{DxDocument, DxLlmValue, DxSection, document_to_llm, llm_to_document};
36
37// Re-export serializer for external use
38pub use serializer as dx_serializer;
39
40// Re-export markdown integration
41pub use markdown::{DxDocumentable, DxMarkdownConfig, DxMarkdownFormat, rules_to_dx_markdown};
42
43// Re-export legacy format converters
44pub use legacy::{LegacyConverter, LegacyFormat, LegacySerializable};
45
46/// Trait for types that can be serialized to/from DX formats
47pub trait DxSerializable: Sized {
48    /// Serialize to DX LLM format (human/LLM readable text)
49    fn to_dx_llm(&self) -> Result<String>;
50
51    /// Deserialize from DX LLM format
52    fn from_dx_llm(content: &str) -> Result<Self>;
53
54    /// Serialize to DX Machine format (binary)
55    fn to_dx_machine(&self) -> Result<Vec<u8>>;
56
57    /// Deserialize from DX Machine format
58    fn from_dx_machine(data: &[u8]) -> Result<Self>;
59}
60
61impl DxSerializable for DrivenConfig {
62    fn to_dx_llm(&self) -> Result<String> {
63        let mut doc = DxDocument::new();
64
65        // Add metadata to context
66        doc.context.insert(
67            "nm".to_string(),
68            DxLlmValue::Str("driven-config".to_string()),
69        );
70        doc.context
71            .insert("v".to_string(), DxLlmValue::Str(self.version.clone()));
72        doc.context.insert(
73            "editor".to_string(),
74            DxLlmValue::Str(self.default_editor.to_string()),
75        );
76
77        // Add editor configuration as section 'e'
78        let mut editors_section = DxSection::new(vec!["name".to_string(), "enabled".to_string()]);
79        editors_section
80            .add_row(vec![
81                DxLlmValue::Str("cursor".to_string()),
82                DxLlmValue::Bool(self.editors.cursor),
83            ])
84            .ok();
85        editors_section
86            .add_row(vec![
87                DxLlmValue::Str("copilot".to_string()),
88                DxLlmValue::Bool(self.editors.copilot),
89            ])
90            .ok();
91        editors_section
92            .add_row(vec![
93                DxLlmValue::Str("windsurf".to_string()),
94                DxLlmValue::Bool(self.editors.windsurf),
95            ])
96            .ok();
97        editors_section
98            .add_row(vec![
99                DxLlmValue::Str("claude".to_string()),
100                DxLlmValue::Bool(self.editors.claude),
101            ])
102            .ok();
103        editors_section
104            .add_row(vec![
105                DxLlmValue::Str("aider".to_string()),
106                DxLlmValue::Bool(self.editors.aider),
107            ])
108            .ok();
109        editors_section
110            .add_row(vec![
111                DxLlmValue::Str("cline".to_string()),
112                DxLlmValue::Bool(self.editors.cline),
113            ])
114            .ok();
115        doc.sections.insert('e', editors_section);
116
117        // Add sync configuration as section 's'
118        let mut sync_section = DxSection::new(vec!["key".to_string(), "value".to_string()]);
119        sync_section
120            .add_row(vec![
121                DxLlmValue::Str("watch".to_string()),
122                DxLlmValue::Bool(self.sync.watch),
123            ])
124            .ok();
125        sync_section
126            .add_row(vec![
127                DxLlmValue::Str("auto_convert".to_string()),
128                DxLlmValue::Bool(self.sync.auto_convert),
129            ])
130            .ok();
131        sync_section
132            .add_row(vec![
133                DxLlmValue::Str("source".to_string()),
134                DxLlmValue::Str(self.sync.source_of_truth.clone()),
135            ])
136            .ok();
137        doc.sections.insert('s', sync_section);
138
139        Ok(document_to_llm(&doc))
140    }
141
142    fn from_dx_llm(content: &str) -> Result<Self> {
143        let doc = llm_to_document(content)
144            .map_err(|e| DrivenError::Parse(format!("DX LLM parse error: {}", e)))?;
145
146        let mut config = DrivenConfig::default();
147
148        // Parse version from context - check both "v" and "vr" (abbreviated)
149        // Handle both string and number values (e.g., "1.0" might be parsed as number 1.0)
150        if let Some(v) = doc.context.get("v").or_else(|| doc.context.get("vr")) {
151            config.version = match v {
152                DxLlmValue::Str(s) => s.clone(),
153                DxLlmValue::Num(n) => {
154                    // Format number back to string, preserving decimal point
155                    if n.fract() == 0.0 {
156                        format!("{}.0", *n as i64)
157                    } else {
158                        format!("{}", n)
159                    }
160                }
161                _ => config.version.clone(),
162            };
163        }
164
165        // Parse default editor from context - check both "editor" and "ed" (abbreviated)
166        if let Some(v) = doc.context.get("editor").or_else(|| doc.context.get("ed")) {
167            if let Some(s) = v.as_str() {
168                config.default_editor = parse_editor(s)?;
169            }
170        }
171
172        // Parse editors section - the schema uses abbreviated column names (nm, en)
173        if let Some(section) = doc.sections.get(&'e') {
174            for row in &section.rows {
175                if row.len() >= 2 {
176                    // First column is name (nm), second is enabled (en)
177                    if let (Some(name), Some(enabled)) = (row[0].as_str(), row[1].as_bool()) {
178                        match name {
179                            "cursor" => config.editors.cursor = enabled,
180                            "copilot" => config.editors.copilot = enabled,
181                            "windsurf" => config.editors.windsurf = enabled,
182                            "claude" => config.editors.claude = enabled,
183                            "aider" => config.editors.aider = enabled,
184                            "cline" => config.editors.cline = enabled,
185                            _ => {}
186                        }
187                    }
188                }
189            }
190        }
191
192        // Parse sync section - the schema uses abbreviated column names (ky, vl)
193        if let Some(section) = doc.sections.get(&'s') {
194            for row in &section.rows {
195                if row.len() >= 2 {
196                    // First column is key (ky), second is value (vl)
197                    if let Some(key) = row[0].as_str() {
198                        match key {
199                            "watch" => {
200                                if let Some(v) = row[1].as_bool() {
201                                    config.sync.watch = v;
202                                }
203                            }
204                            "auto_convert" => {
205                                if let Some(v) = row[1].as_bool() {
206                                    config.sync.auto_convert = v;
207                                }
208                            }
209                            "source" => {
210                                // Handle both string and number values
211                                config.sync.source_of_truth = match &row[1] {
212                                    DxLlmValue::Str(s) => s.clone(),
213                                    DxLlmValue::Num(n) => {
214                                        if n.fract() == 0.0 {
215                                            format!("{}", *n as i64)
216                                        } else {
217                                            format!("{}", n)
218                                        }
219                                    }
220                                    _ => config.sync.source_of_truth.clone(),
221                                };
222                            }
223                            _ => {}
224                        }
225                    }
226                }
227            }
228        }
229
230        Ok(config)
231    }
232
233    fn to_dx_machine(&self) -> Result<Vec<u8>> {
234        // Use a simpler binary format for DrivenConfig
235        // Layout:
236        // [0-3]: Magic "DRV1"
237        // [4]: editor_flags
238        // [5]: sync_flags
239        // [6]: default_editor
240        // [7]: reserved
241        // [8-9]: version_len (u16)
242        // [10-11]: source_len (u16)
243        // [12..]: version string, then source string
244
245        let version_bytes = self.version.as_bytes();
246        let source_bytes = self.sync.source_of_truth.as_bytes();
247
248        let total_len = 12 + version_bytes.len() + source_bytes.len();
249        let mut buffer = Vec::with_capacity(total_len);
250
251        // Magic
252        buffer.extend_from_slice(b"DRV1");
253
254        // Editor flags
255        let editor_flags: u8 = (self.editors.cursor as u8)
256            | ((self.editors.copilot as u8) << 1)
257            | ((self.editors.windsurf as u8) << 2)
258            | ((self.editors.claude as u8) << 3)
259            | ((self.editors.aider as u8) << 4)
260            | ((self.editors.cline as u8) << 5);
261        buffer.push(editor_flags);
262
263        // Sync flags
264        let sync_flags: u8 = (self.sync.watch as u8) | ((self.sync.auto_convert as u8) << 1);
265        buffer.push(sync_flags);
266
267        // Default editor
268        buffer.push(self.default_editor as u8);
269
270        // Reserved
271        buffer.push(0);
272
273        // String lengths
274        buffer.extend_from_slice(&(version_bytes.len() as u16).to_le_bytes());
275        buffer.extend_from_slice(&(source_bytes.len() as u16).to_le_bytes());
276
277        // Strings
278        buffer.extend_from_slice(version_bytes);
279        buffer.extend_from_slice(source_bytes);
280
281        Ok(buffer)
282    }
283
284    fn from_dx_machine(data: &[u8]) -> Result<Self> {
285        if data.len() < 12 {
286            return Err(DrivenError::InvalidBinary(
287                "Data too short for DrivenConfig".to_string(),
288            ));
289        }
290
291        // Check magic
292        if &data[0..4] != b"DRV1" {
293            return Err(DrivenError::InvalidBinary(
294                "Invalid magic bytes".to_string(),
295            ));
296        }
297
298        let editor_flags = data[4];
299        let sync_flags = data[5];
300        let default_editor_byte = data[6];
301
302        let version_len = u16::from_le_bytes([data[8], data[9]]) as usize;
303        let source_len = u16::from_le_bytes([data[10], data[11]]) as usize;
304
305        if data.len() < 12 + version_len + source_len {
306            return Err(DrivenError::InvalidBinary(
307                "Data too short for strings".to_string(),
308            ));
309        }
310
311        let version = std::str::from_utf8(&data[12..12 + version_len])
312            .map_err(|e| DrivenError::InvalidBinary(format!("Invalid UTF-8 in version: {}", e)))?
313            .to_string();
314
315        let source_of_truth =
316            std::str::from_utf8(&data[12 + version_len..12 + version_len + source_len])
317                .map_err(|e| DrivenError::InvalidBinary(format!("Invalid UTF-8 in source: {}", e)))?
318                .to_string();
319
320        Ok(DrivenConfig {
321            version,
322            default_editor: editor_from_byte(default_editor_byte)?,
323            editors: EditorConfig {
324                cursor: (editor_flags & 1) != 0,
325                copilot: (editor_flags & 2) != 0,
326                windsurf: (editor_flags & 4) != 0,
327                claude: (editor_flags & 8) != 0,
328                aider: (editor_flags & 16) != 0,
329                cline: (editor_flags & 32) != 0,
330            },
331            sync: crate::SyncConfig {
332                watch: (sync_flags & 1) != 0,
333                auto_convert: (sync_flags & 2) != 0,
334                source_of_truth,
335            },
336            templates: crate::TemplateConfig::default(),
337            context: crate::ContextConfig::default(),
338        })
339    }
340}
341
342/// Read a string from a DX-Zero slot (kept for future use with more complex types)
343#[allow(dead_code)]
344fn read_slot_string(_data: &[u8], _slot_offset: usize) -> Result<String> {
345    // TODO: Re-implement when serializer::zero module is available
346    Err(DrivenError::InvalidBinary("Not implemented".to_string()))
347    /*
348    use serializer::zero::slot::{HEAP_MARKER, INLINE_MARKER};
349
350    if slot_offset + 16 > data.len() {
351        return Err(DrivenError::InvalidBinary("Slot offset out of bounds".to_string()));
352    }
353
354    let slot_data = &data[slot_offset..slot_offset + 16];
355    let marker = slot_data[15];
356
357    if marker == INLINE_MARKER || marker < 14 {
358        // Inline string: length is in byte 0, data in bytes 1..1+len
359        let len = slot_data[0] as usize;
360        if len > 14 {
361            return Err(DrivenError::InvalidBinary("Invalid inline string length".to_string()));
362        }
363        let s = std::str::from_utf8(&slot_data[1..1 + len])
364            .map_err(|e| DrivenError::InvalidBinary(format!("Invalid UTF-8: {}", e)))?;
365        Ok(s.to_string())
366    } else if marker == HEAP_MARKER {
367        // Heap string: offset in bytes 0-3, length in bytes 4-7
368        let offset =
369            u32::from_le_bytes([slot_data[0], slot_data[1], slot_data[2], slot_data[3]]) as usize;
370        let len =
371            u32::from_le_bytes([slot_data[4], slot_data[5], slot_data[6], slot_data[7]]) as usize;
372
373        // Calculate actual heap position
374        let heap_start = slot_offset + offset;
375        if heap_start + len > data.len() {
376            return Err(DrivenError::InvalidBinary("Heap string out of bounds".to_string()));
377        }
378
379        let s = std::str::from_utf8(&data[heap_start..heap_start + len])
380            .map_err(|e| DrivenError::InvalidBinary(format!("Invalid UTF-8: {}", e)))?;
381        Ok(s.to_string())
382    } else {
383        Err(DrivenError::InvalidBinary(format!("Unknown slot marker: 0x{:02x}", marker)))
384    }
385    */
386}
387
388/// Parse editor from string
389fn parse_editor(s: &str) -> Result<crate::Editor> {
390    match s.to_lowercase().as_str() {
391        "cursor" => Ok(crate::Editor::Cursor),
392        "copilot" | "github copilot" => Ok(crate::Editor::Copilot),
393        "windsurf" => Ok(crate::Editor::Windsurf),
394        "claude" | "claude code" => Ok(crate::Editor::Claude),
395        "aider" => Ok(crate::Editor::Aider),
396        "cline" => Ok(crate::Editor::Cline),
397        _ => Err(DrivenError::Parse(format!("Unknown editor: {}", s))),
398    }
399}
400
401/// Convert editor byte to Editor enum
402fn editor_from_byte(byte: u8) -> Result<crate::Editor> {
403    match byte {
404        0 => Ok(crate::Editor::Cursor),
405        1 => Ok(crate::Editor::Copilot),
406        2 => Ok(crate::Editor::Windsurf),
407        3 => Ok(crate::Editor::Claude),
408        4 => Ok(crate::Editor::Aider),
409        5 => Ok(crate::Editor::Cline),
410        _ => Err(DrivenError::InvalidBinary(format!(
411            "Unknown editor byte: {}",
412            byte
413        ))),
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_driven_config_dx_llm_roundtrip() {
423        let config = DrivenConfig::default();
424        let llm = config.to_dx_llm().unwrap();
425        println!("Serialized LLM:\n{}", llm);
426        let loaded = DrivenConfig::from_dx_llm(&llm).unwrap();
427
428        assert_eq!(config.version, loaded.version);
429        assert_eq!(config.editors.cursor, loaded.editors.cursor);
430        assert_eq!(config.editors.copilot, loaded.editors.copilot);
431        assert_eq!(config.sync.watch, loaded.sync.watch);
432    }
433
434    #[test]
435    fn test_driven_config_dx_llm_roundtrip_numeric_version() {
436        let mut config = DrivenConfig::default();
437        config.version = "0.0".to_string();
438        let llm = config.to_dx_llm().unwrap();
439        println!("Serialized LLM:\n{}", llm);
440        let loaded = DrivenConfig::from_dx_llm(&llm).unwrap();
441        println!("Loaded version: {}", loaded.version);
442
443        assert_eq!(config.version, loaded.version);
444    }
445
446    #[test]
447    fn test_driven_config_dx_machine_roundtrip() {
448        let config = DrivenConfig::default();
449        let binary = config.to_dx_machine().unwrap();
450        let loaded = DrivenConfig::from_dx_machine(&binary).unwrap();
451
452        assert_eq!(config.version, loaded.version);
453        assert_eq!(config.editors.cursor, loaded.editors.cursor);
454        assert_eq!(config.editors.copilot, loaded.editors.copilot);
455        assert_eq!(config.sync.watch, loaded.sync.watch);
456    }
457}
458
459#[cfg(test)]
460mod prop_tests {
461    use super::*;
462    use proptest::prelude::*;
463
464    /// Generate arbitrary Editor values
465    fn arb_editor() -> impl Strategy<Value = crate::Editor> {
466        prop_oneof![
467            Just(crate::Editor::Cursor),
468            Just(crate::Editor::Copilot),
469            Just(crate::Editor::Windsurf),
470            Just(crate::Editor::Claude),
471            Just(crate::Editor::Aider),
472            Just(crate::Editor::Cline),
473        ]
474    }
475
476    /// Generate arbitrary EditorConfig values
477    fn arb_editor_config() -> impl Strategy<Value = EditorConfig> {
478        (
479            any::<bool>(),
480            any::<bool>(),
481            any::<bool>(),
482            any::<bool>(),
483            any::<bool>(),
484            any::<bool>(),
485        )
486            .prop_map(
487                |(cursor, copilot, windsurf, claude, aider, cline)| EditorConfig {
488                    cursor,
489                    copilot,
490                    windsurf,
491                    claude,
492                    aider,
493                    cline,
494                },
495            )
496    }
497
498    /// Generate arbitrary SyncConfig values
499    fn arb_sync_config() -> impl Strategy<Value = crate::SyncConfig> {
500        (any::<bool>(), any::<bool>(), "[a-zA-Z0-9_./]{1,50}").prop_map(
501            |(watch, auto_convert, source)| crate::SyncConfig {
502                watch,
503                auto_convert,
504                source_of_truth: source,
505            },
506        )
507    }
508
509    /// Generate arbitrary DrivenConfig values
510    fn arb_driven_config() -> impl Strategy<Value = DrivenConfig> {
511        (
512            // Use version format that won't be parsed as a number (e.g., "v1.0.0")
513            "[0-9]+\\.[0-9]+\\.[0-9]+",
514            arb_editor(),
515            arb_editor_config(),
516            arb_sync_config(),
517        )
518            .prop_map(|(version, default_editor, editors, sync)| DrivenConfig {
519                version: format!("v{}", version), // Prefix with 'v' to ensure it's a string
520                default_editor,
521                editors,
522                sync,
523                templates: crate::TemplateConfig::default(),
524                context: crate::ContextConfig::default(),
525            })
526    }
527
528    proptest! {
529        /// Property 1: DX Serializer Round-Trip Consistency
530        /// *For any* valid DrivenConfig, serializing to DX LLM format and
531        /// deserializing back SHALL produce an equivalent object.
532        /// **Validates: Requirements 1.1, 1.2, 1.4, 1.5**
533        #[test]
534        fn prop_dx_llm_roundtrip(config in arb_driven_config()) {
535            let llm = config.to_dx_llm().expect("Serialization should succeed");
536            let loaded = DrivenConfig::from_dx_llm(&llm).expect("Deserialization should succeed");
537
538            // Verify key fields are preserved
539            prop_assert_eq!(config.version, loaded.version);
540            prop_assert_eq!(config.default_editor, loaded.default_editor);
541            prop_assert_eq!(config.editors.cursor, loaded.editors.cursor);
542            prop_assert_eq!(config.editors.copilot, loaded.editors.copilot);
543            prop_assert_eq!(config.editors.windsurf, loaded.editors.windsurf);
544            prop_assert_eq!(config.editors.claude, loaded.editors.claude);
545            prop_assert_eq!(config.editors.aider, loaded.editors.aider);
546            prop_assert_eq!(config.editors.cline, loaded.editors.cline);
547            prop_assert_eq!(config.sync.watch, loaded.sync.watch);
548            prop_assert_eq!(config.sync.auto_convert, loaded.sync.auto_convert);
549            prop_assert_eq!(config.sync.source_of_truth, loaded.sync.source_of_truth);
550        }
551
552        /// Property 2: DX Machine Format Round-Trip Consistency
553        /// *For any* valid DrivenConfig, encoding to DX Machine format and
554        /// decoding back SHALL produce an equivalent object.
555        /// **Validates: Requirements 1.2**
556        #[test]
557        fn prop_dx_machine_roundtrip(config in arb_driven_config()) {
558            let binary = config.to_dx_machine().expect("Serialization should succeed");
559            let loaded = DrivenConfig::from_dx_machine(&binary).expect("Deserialization should succeed");
560
561            // Verify key fields are preserved
562            prop_assert_eq!(config.version, loaded.version);
563            prop_assert_eq!(config.default_editor, loaded.default_editor);
564            prop_assert_eq!(config.editors.cursor, loaded.editors.cursor);
565            prop_assert_eq!(config.editors.copilot, loaded.editors.copilot);
566            prop_assert_eq!(config.editors.windsurf, loaded.editors.windsurf);
567            prop_assert_eq!(config.editors.claude, loaded.editors.claude);
568            prop_assert_eq!(config.editors.aider, loaded.editors.aider);
569            prop_assert_eq!(config.editors.cline, loaded.editors.cline);
570            prop_assert_eq!(config.sync.watch, loaded.sync.watch);
571            prop_assert_eq!(config.sync.auto_convert, loaded.sync.auto_convert);
572            prop_assert_eq!(config.sync.source_of_truth, loaded.sync.source_of_truth);
573        }
574    }
575}