hedl_cli/commands/
format.rs

1// Dweve HEDL - Hierarchical Entity Data Language
2//
3// Copyright (c) 2025 Dweve IP B.V. and individual contributors.
4//
5// SPDX-License-Identifier: Apache-2.0
6//
7// Licensed under the Apache License, Version 2.0 (the "License");
8// you may not use this file except in compliance with the License.
9// You may obtain a copy of the License in the LICENSE file at the
10// root of this repository or at: http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! Format command - HEDL canonicalization and formatting
19
20use super::{read_file, write_output};
21use crate::error::CliError;
22use hedl_c14n::{canonicalize_with_config, CanonicalConfig};
23use hedl_core::{parse, Document, Item};
24
25/// Format a HEDL file to canonical form.
26///
27/// Parses a HEDL file and outputs it in canonical (standardized) form. Can be used
28/// to check if a file is already canonical, or to add count hints to all matrix lists.
29///
30/// # Arguments
31///
32/// * `file` - Path to the HEDL file to format
33/// * `output` - Optional output file path. If `None`, writes to stdout
34/// * `check` - If `true`, only checks if the file is canonical without reformatting
35/// * `ditto` - If `true`, uses ditto optimization (repeated values as `"`)
36/// * `with_counts` - If `true`, automatically adds count hints to all matrix lists
37///
38/// # Returns
39///
40/// Returns `Ok(())` on success, or `Err` if:
41/// - The file cannot be read or parsed
42/// - In check mode, if the file is not in canonical form
43/// - Output cannot be written
44///
45/// # Errors
46///
47/// Returns `Err` if:
48/// - The file cannot be read
49/// - The file contains syntax errors
50/// - Canonicalization fails
51/// - In check mode, if the file is not already canonical
52/// - Output writing fails
53///
54/// # Examples
55///
56/// ```no_run
57/// use hedl_cli::commands::format;
58///
59/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
60/// // Format to stdout
61/// format("input.hedl", None, false, true, false)?;
62///
63/// // Format to file with count hints
64/// format("input.hedl", Some("output.hedl"), false, true, true)?;
65///
66/// // Check if file is already canonical
67/// let result = format("input.hedl", None, true, true, false);
68/// if result.is_ok() {
69///     println!("File is already canonical");
70/// }
71///
72/// // Disable ditto optimization
73/// format("input.hedl", Some("output.hedl"), false, false, false)?;
74/// # Ok(())
75/// # }
76/// ```
77///
78/// # Output
79///
80/// In check mode, prints "File is in canonical form" if valid, or returns an error.
81/// Otherwise, writes the canonical HEDL to the specified output or stdout.
82pub fn format(
83    file: &str,
84    output: Option<&str>,
85    check: bool,
86    ditto: bool,
87    with_counts: bool,
88) -> Result<(), CliError> {
89    let content = read_file(file)?;
90
91    let mut doc =
92        parse(content.as_bytes()).map_err(|e| CliError::parse(format!("Parse error: {e}")))?;
93
94    // Add count hints if requested
95    if with_counts {
96        add_count_hints_to_doc(&mut doc);
97    }
98
99    let mut config = CanonicalConfig::default();
100    config.use_ditto = ditto;
101
102    let canonical = canonicalize_with_config(&doc, &config)
103        .map_err(|e| CliError::canonicalization(format!("Canonicalization error: {e}")))?;
104
105    if check {
106        // Compare with original (normalized)
107        let normalized_original = content.replace("\r\n", "\n");
108        if canonical.trim() != normalized_original.trim() {
109            return Err(CliError::NotCanonical);
110        }
111        println!("File is in canonical form");
112        Ok(())
113    } else {
114        write_output(&canonical, output)
115    }
116}
117
118/// Recursively add count hints to all matrix lists in the document
119fn add_count_hints_to_doc(doc: &mut Document) {
120    for item in doc.root.values_mut() {
121        add_count_hints_to_item(item);
122    }
123}
124
125/// Recursively add count hints to an item
126fn add_count_hints_to_item(item: &mut Item) {
127    match item {
128        Item::List(list) => {
129            // Set count hint based on actual row count
130            list.count_hint = Some(list.rows.len());
131
132            // Recursively add child counts to each node
133            for node in &mut list.rows {
134                add_child_count_to_node(node);
135            }
136        }
137        Item::Object(map) => {
138            // Recursively process nested objects
139            for nested_item in map.values_mut() {
140                add_count_hints_to_item(nested_item);
141            }
142        }
143        Item::Scalar(_) => {
144            // Scalars don't have matrix lists
145        }
146    }
147}
148
149/// Recursively set `child_count` on nodes that have children
150fn add_child_count_to_node(node: &mut hedl_core::Node) {
151    // Calculate total number of direct children across all child types
152    let total_children: usize = node
153        .children()
154        .map_or(0, |c| c.values().map(std::vec::Vec::len).sum());
155
156    if total_children > 0 {
157        node.child_count = total_children.min(u16::MAX as usize) as u16;
158
159        // Recursively process all child nodes
160        if let Some(children) = node.children_mut() {
161            for child_list in children.values_mut() {
162                for child_node in child_list {
163                    add_child_count_to_node(child_node);
164                }
165            }
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use hedl_core::{MatrixList, Node, Value};
174
175    #[test]
176    fn test_add_count_hints_to_empty_list() {
177        let list = MatrixList::new("Team", vec!["id".to_string(), "name".to_string()]);
178        assert_eq!(list.count_hint, None);
179
180        let mut item = Item::List(list);
181        add_count_hints_to_item(&mut item);
182
183        if let Item::List(list) = item {
184            assert_eq!(list.count_hint, Some(0));
185        } else {
186            panic!("Expected List item");
187        }
188    }
189
190    #[test]
191    fn test_add_count_hints_to_list_with_rows() {
192        let mut list = MatrixList::new("Team", vec!["id".to_string(), "name".to_string()]);
193        list.add_row(Node::new(
194            "Team",
195            "t1",
196            vec![Value::String("Team 1".into())],
197        ));
198        list.add_row(Node::new(
199            "Team",
200            "t2",
201            vec![Value::String("Team 2".into())],
202        ));
203        list.add_row(Node::new(
204            "Team",
205            "t3",
206            vec![Value::String("Team 3".into())],
207        ));
208        assert_eq!(list.count_hint, None);
209
210        let mut item = Item::List(list);
211        add_count_hints_to_item(&mut item);
212
213        if let Item::List(list) = item {
214            assert_eq!(list.count_hint, Some(3));
215            assert_eq!(list.rows.len(), 3);
216        } else {
217            panic!("Expected List item");
218        }
219    }
220
221    #[test]
222    fn test_add_count_hints_overwrites_existing() {
223        let mut list =
224            MatrixList::with_count_hint("Team", vec!["id".to_string(), "name".to_string()], 5);
225        list.add_row(Node::new(
226            "Team",
227            "t1",
228            vec![Value::String("Team 1".into())],
229        ));
230        list.add_row(Node::new(
231            "Team",
232            "t2",
233            vec![Value::String("Team 2".into())],
234        ));
235        assert_eq!(list.count_hint, Some(5)); // Old value
236
237        let mut item = Item::List(list);
238        add_count_hints_to_item(&mut item);
239
240        if let Item::List(list) = item {
241            assert_eq!(list.count_hint, Some(2)); // Updated to actual count
242            assert_eq!(list.rows.len(), 2);
243        } else {
244            panic!("Expected List item");
245        }
246    }
247
248    #[test]
249    fn test_add_count_hints_to_nested_objects() {
250        use std::collections::BTreeMap;
251
252        let mut list1 = MatrixList::new("Team", vec!["id".to_string()]);
253        list1.add_row(Node::new("Team", "t1", vec![]));
254
255        let mut list2 = MatrixList::new("Player", vec!["id".to_string()]);
256        list2.add_row(Node::new("Player", "p1", vec![]));
257        list2.add_row(Node::new("Player", "p2", vec![]));
258
259        let mut inner_map = BTreeMap::new();
260        inner_map.insert("teams".to_string(), Item::List(list1));
261
262        let mut outer_map = BTreeMap::new();
263        outer_map.insert("sports".to_string(), Item::Object(inner_map));
264        outer_map.insert("players".to_string(), Item::List(list2));
265
266        let mut item = Item::Object(outer_map);
267        add_count_hints_to_item(&mut item);
268
269        // Verify nested structure has count hints
270        if let Item::Object(map) = item {
271            // Check teams nested in sports
272            if let Some(Item::Object(sports)) = map.get("sports") {
273                if let Some(Item::List(teams)) = sports.get("teams") {
274                    assert_eq!(teams.count_hint, Some(1));
275                } else {
276                    panic!("Expected teams list in sports");
277                }
278            } else {
279                panic!("Expected sports object");
280            }
281
282            // Check players at top level
283            if let Some(Item::List(players)) = map.get("players") {
284                assert_eq!(players.count_hint, Some(2));
285            } else {
286                panic!("Expected players list");
287            }
288        } else {
289            panic!("Expected Object item");
290        }
291    }
292
293    #[test]
294    fn test_add_count_hints_to_scalar() {
295        let mut item = Item::Scalar(Value::String("test".into()));
296        add_count_hints_to_item(&mut item);
297        // Should not panic, just do nothing
298        assert!(matches!(item, Item::Scalar(_)));
299    }
300
301    #[test]
302    fn test_add_count_hints_to_empty_object() {
303        use std::collections::BTreeMap;
304
305        let mut item = Item::Object(BTreeMap::new());
306        add_count_hints_to_item(&mut item);
307        // Should not panic, just do nothing
308        assert!(matches!(item, Item::Object(_)));
309    }
310
311    #[test]
312    fn test_add_count_hints_document() {
313        let mut doc = Document::new((1, 0));
314
315        let mut list1 = MatrixList::new("Team", vec!["id".to_string()]);
316        list1.add_row(Node::new("Team", "t1", vec![]));
317        list1.add_row(Node::new("Team", "t2", vec![]));
318
319        let mut list2 = MatrixList::new("Player", vec!["id".to_string()]);
320        list2.add_row(Node::new("Player", "p1", vec![]));
321
322        doc.root.insert("teams".to_string(), Item::List(list1));
323        doc.root.insert("players".to_string(), Item::List(list2));
324
325        add_count_hints_to_doc(&mut doc);
326
327        // Verify both lists have count hints
328        if let Some(Item::List(teams)) = doc.root.get("teams") {
329            assert_eq!(teams.count_hint, Some(2));
330        } else {
331            panic!("Expected teams list");
332        }
333
334        if let Some(Item::List(players)) = doc.root.get("players") {
335            assert_eq!(players.count_hint, Some(1));
336        } else {
337            panic!("Expected players list");
338        }
339    }
340}