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