Skip to main content

serializer/llm/
human_formatter.rs

1//! Human Format Formatter
2//!
3//! Converts DxDocument to human-readable format for editing in text editors.
4//!
5//! ## Human Format Syntax (matching TypeScript implementation)
6//!
7//! ```dx
8//! # Root scalars: key = value (padded to column 28)
9//! name                        = dx
10//! version                     = 0.0.1
11//! title                       = Enhanced Developing Experience
12//!
13//! # [section] headers for grouped data
14//! [workspace]
15//! paths:
16//! - @/www
17//! - @/backend
18//!
19//! [editors]
20//! default                     = neovim
21//! items:
22//! - neovim
23//! - zed
24//! - vscode
25//!
26//! # Tabular sections (multiple rows with name/version)
27//! [dependencies]
28//! dx-package-1                = 0.0.1
29//! dx-package-2                = 0.0.1
30//! ```
31
32use crate::llm::types::{DxDocument, DxLlmValue, DxSection};
33use indexmap::IndexMap;
34
35/// Configuration for Human Format output
36#[derive(Debug, Clone)]
37pub struct HumanFormatConfig {
38    /// Key padding to align = at column 28 (TypeScript default)
39    pub key_padding: usize,
40}
41
42impl Default for HumanFormatConfig {
43    fn default() -> Self {
44        Self { key_padding: 28 }
45    }
46}
47
48impl HumanFormatConfig {
49    /// Create a default human-format configuration.
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Set the padding width used before assignment operators.
55    pub fn with_key_padding(mut self, padding: usize) -> Self {
56        self.key_padding = padding;
57        self
58    }
59
60    /// Create the default configuration used for tabular human output.
61    pub fn for_tables() -> Self {
62        Self { key_padding: 28 }
63    }
64}
65
66/// Human Formatter - Clean TOML/INI-like format matching TypeScript implementation
67pub struct HumanFormatter {
68    config: HumanFormatConfig,
69}
70
71impl HumanFormatter {
72    /// Create a formatter with default human-format settings.
73    pub fn new() -> Self {
74        Self {
75            config: HumanFormatConfig::default(),
76        }
77    }
78
79    /// Create a formatter with explicit human-format settings.
80    pub fn with_config(config: HumanFormatConfig) -> Self {
81        Self { config }
82    }
83
84    /// Format DxDocument to Human Format string
85    pub fn format(&self, doc: &DxDocument) -> String {
86        let mut sections: Vec<String> = Vec::new();
87
88        // If entry_order is populated, use it to maintain original order
89        if !doc.entry_order.is_empty() {
90            // First, collect root scalars
91            let mut root_scalar_keys = Vec::new();
92            for entry_ref in &doc.entry_order {
93                if let crate::llm::types::EntryRef::Context(key) = entry_ref {
94                    if let Some(value) = doc.context.get(key) {
95                        if !matches!(value, DxLlmValue::Obj(_) | DxLlmValue::Arr(_)) {
96                            root_scalar_keys.push(key.clone());
97                        }
98                    }
99                }
100            }
101
102            if !root_scalar_keys.is_empty() {
103                let mut root_scalars_map = IndexMap::new();
104                for key in root_scalar_keys {
105                    if let Some(value) = doc.context.get(&key) {
106                        root_scalars_map.insert(key, value.clone());
107                    }
108                }
109                let root_scalars = self.format_root_scalars(&root_scalars_map);
110                if !root_scalars.is_empty() {
111                    sections.push(root_scalars);
112                }
113            }
114
115            // Then process entries in order
116            for entry_ref in &doc.entry_order {
117                match entry_ref {
118                    crate::llm::types::EntryRef::Context(key) => {
119                        if let Some(DxLlmValue::Obj(fields)) = doc.context.get(key) {
120                            let section_output = self.format_object_as_section(key, fields);
121                            if !section_output.is_empty() {
122                                sections.push(section_output);
123                            }
124                        }
125                    }
126                    crate::llm::types::EntryRef::Section(id) => {
127                        if let Some(section) = doc.sections.get(id) {
128                            let section_name = doc
129                                .section_names
130                                .get(id)
131                                .cloned()
132                                .unwrap_or_else(|| id.to_string());
133
134                            let section_output = if self.is_tabular_section(section) {
135                                self.format_tabular_section_multi_row(&section_name, section)
136                            } else {
137                                self.format_section(&section_name, section)
138                            };
139
140                            if !section_output.is_empty() {
141                                sections.push(section_output);
142                            }
143                        }
144                    }
145                }
146            }
147        } else {
148            // Fallback: use old behavior if entry_order is not populated
149            let root_scalars = self.format_root_scalars(&doc.context);
150            if !root_scalars.is_empty() {
151                sections.push(root_scalars);
152            }
153
154            let mut context_keys: Vec<_> = doc.context.keys().collect();
155            context_keys.sort();
156
157            for key in context_keys {
158                if let Some(DxLlmValue::Obj(fields)) = doc.context.get(key) {
159                    let section_output = self.format_object_as_section(key, fields);
160                    if !section_output.is_empty() {
161                        sections.push(section_output);
162                    }
163                }
164            }
165
166            let mut section_ids: Vec<_> = doc.sections.keys().collect();
167            section_ids.sort();
168
169            for id in section_ids {
170                if let Some(section) = doc.sections.get(id) {
171                    let section_name = doc
172                        .section_names
173                        .get(id)
174                        .cloned()
175                        .unwrap_or_else(|| id.to_string());
176
177                    let section_output = if self.is_tabular_section(section) {
178                        self.format_tabular_section_multi_row(&section_name, section)
179                    } else {
180                        self.format_section(&section_name, section)
181                    };
182
183                    if !section_output.is_empty() {
184                        sections.push(section_output);
185                    }
186                }
187            }
188        }
189
190        sections.join("\n\n")
191    }
192
193    /// Format an object from context as a [section]
194    fn format_object_as_section(
195        &self,
196        section_id: &str,
197        fields: &IndexMap<String, DxLlmValue>,
198    ) -> String {
199        let mut lines: Vec<String> = Vec::new();
200
201        lines.push(format!("[{}]", section_id));
202
203        // Separate scalars and arrays
204        let mut scalars: Vec<(String, String)> = Vec::new();
205        let mut arrays: Vec<(String, Vec<String>)> = Vec::new();
206
207        for (key, value) in fields {
208            if let DxLlmValue::Arr(items) = value {
209                let array_items: Vec<String> = items.iter().map(|v| self.format_value(v)).collect();
210                arrays.push((key.clone(), array_items));
211            } else {
212                scalars.push((key.clone(), self.format_value(value)));
213            }
214        }
215
216        // Output scalars first
217        for (key, value) in scalars {
218            lines.push(self.format_key_value(&key, &value));
219        }
220
221        // Output arrays (key: followed by - item lines)
222        for (key, items) in arrays {
223            lines.push(format!("{}:", key));
224            for item in items {
225                lines.push(format!("- {}", item));
226            }
227        }
228
229        lines.join("\n")
230    }
231
232    /// Format root scalars (context values that are not arrays or objects)
233    fn format_root_scalars(&self, context: &IndexMap<String, DxLlmValue>) -> String {
234        let mut lines: Vec<String> = Vec::new();
235
236        for (key, value) in context.iter() {
237            // Skip arrays and objects - they'll be formatted as sections
238            if !matches!(value, DxLlmValue::Arr(_) | DxLlmValue::Obj(_)) {
239                lines.push(self.format_key_value(key, &self.format_value(value)));
240            }
241        }
242
243        lines.join("\n")
244    }
245
246    /// Format a section with [section] header
247    fn format_section(&self, section_id: &str, section: &DxSection) -> String {
248        let mut lines: Vec<String> = Vec::new();
249
250        lines.push(format!("[{}]", section_id));
251
252        if section.rows.is_empty() {
253            return lines.join("\n");
254        }
255
256        let row = &section.rows[0];
257
258        // Separate scalars and arrays
259        let mut scalars: Vec<(String, String)> = Vec::new();
260        let mut arrays: Vec<(String, Vec<String>)> = Vec::new();
261
262        for (i, field) in section.schema.iter().enumerate() {
263            if i >= row.len() {
264                break;
265            }
266
267            let key = field.clone();
268            let value = &row[i];
269
270            if let DxLlmValue::Arr(items) = value {
271                let array_items: Vec<String> = items.iter().map(|v| self.format_value(v)).collect();
272                arrays.push((key, array_items));
273            } else {
274                scalars.push((key, self.format_value(value)));
275            }
276        }
277
278        // Output scalars first
279        for (key, value) in scalars {
280            lines.push(self.format_key_value(&key, &value));
281        }
282
283        // Output arrays (key: followed by - item lines)
284        for (key, items) in arrays {
285            lines.push(format!("{}:", key));
286            for item in items {
287                lines.push(format!("- {}", item));
288            }
289        }
290
291        lines.join("\n")
292    }
293
294    /// Format tabular section (multiple rows with name/version columns)
295    #[allow(dead_code)]
296    fn format_tabular_section(&self, section_id: &str, section: &DxSection) -> String {
297        let mut lines: Vec<String> = Vec::new();
298
299        lines.push(format!("[{}]", section_id));
300
301        for row in &section.rows {
302            if row.len() >= 2 {
303                let key = self.format_value(&row[0]);
304                let value = self.format_value(&row[1]);
305                lines.push(self.format_key_value(&key, &value));
306            }
307        }
308
309        lines.join("\n")
310    }
311
312    /// Format tabular section with multiple rows as separate [section:N] sections
313    fn format_tabular_section_multi_row(&self, section_id: &str, section: &DxSection) -> String {
314        let mut all_sections: Vec<String> = Vec::new();
315
316        for (row_idx, row) in section.rows.iter().enumerate() {
317            let mut lines: Vec<String> = Vec::new();
318
319            // Section header with row number: [dependencies:1], [dependencies:2], etc.
320            lines.push(format!("[{}:{}]", section_id, row_idx + 1));
321
322            // Format each field as key = value
323            for (i, field) in section.schema.iter().enumerate() {
324                if i >= row.len() {
325                    break;
326                }
327
328                let key = field.clone();
329                let value = &row[i];
330
331                if let DxLlmValue::Arr(items) = value {
332                    // Arrays as key: followed by - items
333                    lines.push(format!("{}:", key));
334                    for item in items {
335                        lines.push(format!("- {}", self.format_value(item)));
336                    }
337                } else {
338                    lines.push(self.format_key_value(&key, &self.format_value(value)));
339                }
340            }
341
342            all_sections.push(lines.join("\n"));
343        }
344
345        all_sections.join("\n\n")
346    }
347
348    /// Check if a section is tabular (has multiple rows with name/version schema)
349    fn is_tabular_section(&self, section: &DxSection) -> bool {
350        if section.rows.len() <= 1 {
351            return false;
352        }
353
354        let schema_names: Vec<String> = section.schema.iter().map(|f| f.to_lowercase()).collect();
355
356        schema_names.contains(&"name".to_string()) && schema_names.contains(&"version".to_string())
357    }
358
359    /// Format a DxValue to string for display
360    fn format_value(&self, value: &DxLlmValue) -> String {
361        match value {
362            DxLlmValue::Str(s) => {
363                // Replace underscores with spaces for human readability
364                s.replace('_', " ")
365            }
366            DxLlmValue::Num(n) => {
367                if n.fract() == 0.0 {
368                    format!("{}", *n as i64)
369                } else {
370                    format!("{}", n)
371                }
372            }
373            DxLlmValue::Bool(b) => {
374                if *b {
375                    "true".to_string()
376                } else {
377                    "false".to_string()
378                }
379            }
380            DxLlmValue::Null => "none".to_string(),
381            DxLlmValue::Arr(_) => String::new(),
382            DxLlmValue::Obj(_) => String::new(),
383            DxLlmValue::Ref(key) => format!("^{}", key),
384        }
385    }
386
387    /// Format a key with padding to align = at column 28
388    fn format_key_value(&self, key: &str, value: &str) -> String {
389        let padding = self.config.key_padding.saturating_sub(key.len()).max(1);
390        format!("{}{} = {}", key, " ".repeat(padding), value)
391    }
392}
393
394impl Default for HumanFormatter {
395    fn default() -> Self {
396        Self::new()
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn test_format_empty_document() {
406        let formatter = HumanFormatter::new();
407        let doc = DxDocument::new();
408        let output = formatter.format(&doc);
409        assert!(output.is_empty());
410    }
411
412    #[test]
413    fn test_format_scalar_values() {
414        let formatter = HumanFormatter::new();
415        let mut doc = DxDocument::new();
416        doc.context
417            .insert("name".to_string(), DxLlmValue::Str("dx".to_string()));
418        doc.context
419            .insert("version".to_string(), DxLlmValue::Str("0.0.1".to_string()));
420        doc.context
421            .insert("count".to_string(), DxLlmValue::Num(42.0));
422        doc.context
423            .insert("active".to_string(), DxLlmValue::Bool(true));
424
425        let output = formatter.format(&doc);
426        assert!(output.contains("name"));
427        assert!(output.contains("dx"));
428        assert!(output.contains("version"));
429        assert!(output.contains("0.0.1"));
430        assert!(output.contains("count"));
431        assert!(output.contains("42"));
432        assert!(output.contains("active"));
433        assert!(output.contains("true"));
434    }
435}