Skip to main content

lino_objects_codec/
format.rs

1//! Formatting utilities for indented Links Notation and individual values.
2
3use super::{LiNo, parse_lino_to_links};
4
5// Public access to the same quoting rules used by both readable encoders.
6pub use crate::readable::{format_value_single_line, format_value_verbatim, quote, unescape};
7use std::collections::HashMap;
8
9/// Error types for format operations
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum FormatError {
12    /// Missing required field
13    MissingField(String),
14    /// Invalid input
15    InvalidInput(String),
16}
17
18impl std::fmt::Display for FormatError {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            FormatError::MissingField(field) => write!(f, "Missing required field: {}", field),
22            FormatError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
23        }
24    }
25}
26
27impl std::error::Error for FormatError {}
28
29/// Escape a reference for Links Notation.
30///
31/// References need escaping when they contain spaces, quotes, parentheses, colons, or newlines.
32///
33/// # Arguments
34///
35/// * `value` - The value to escape
36///
37/// # Returns
38///
39/// The escaped reference string
40pub fn escape_reference(value: &str) -> String {
41    // Check if escaping is needed
42    let needs_escaping = value
43        .chars()
44        .any(|c| c.is_whitespace() || c == '(' || c == ')' || c == '\'' || c == '"' || c == ':')
45        || value.contains('\n');
46
47    if !needs_escaping {
48        return value.to_string();
49    }
50
51    let has_single = value.contains('\'');
52    let has_double = value.contains('"');
53
54    // If contains single quotes but not double quotes, use double quotes
55    if has_single && !has_double {
56        return format!("\"{}\"", value);
57    }
58
59    // If contains double quotes but not single quotes, use single quotes
60    if has_double && !has_single {
61        return format!("'{}'", value);
62    }
63
64    // If contains both quotes, count which one appears more
65    if has_single && has_double {
66        let single_count = value.chars().filter(|&c| c == '\'').count();
67        let double_count = value.chars().filter(|&c| c == '"').count();
68
69        if double_count < single_count {
70            // Use double quotes, escape internal double quotes by doubling
71            let escaped = value.replace('"', "\"\"");
72            return format!("\"{}\"", escaped);
73        }
74        // Use single quotes, escape internal single quotes by doubling
75        let escaped = value.replace('\'', "''");
76        return format!("'{}'", escaped);
77    }
78
79    // Just spaces or other special characters, use single quotes by default
80    format!("'{}'", value)
81}
82
83/// Unescape a reference from Links Notation format.
84///
85/// Reverses the escaping done by escape_reference.
86///
87/// # Arguments
88///
89/// * `s` - The escaped reference string
90///
91/// # Returns
92///
93/// The unescaped string
94pub fn unescape_reference(s: &str) -> String {
95    s.replace("\"\"", "\"").replace("''", "'")
96}
97
98/// Format a value for display in indented Links Notation.
99/// Uses quoting strategy compatible with the links-notation parser:
100/// - If value contains double quotes, wrap in single quotes
101/// - Otherwise, wrap in double quotes
102fn format_indented_value(value: &str) -> String {
103    let has_single = value.contains('\'');
104    let has_double = value.contains('"');
105
106    // If contains double quotes but no single quotes, use single quotes
107    if has_double && !has_single {
108        return format!("'{}'", value);
109    }
110
111    // If contains single quotes but no double quotes, use double quotes
112    if has_single && !has_double {
113        return format!("\"{}\"", value);
114    }
115
116    // If contains both, use single quotes and escape internal single quotes
117    if has_single && has_double {
118        let escaped = value.replace('\'', "''");
119        return format!("'{}'", escaped);
120    }
121
122    // Default: use double quotes
123    format!("\"{}\"", value)
124}
125
126/// Format an object in indented Links Notation format.
127///
128/// This format is designed for human readability, displaying objects as:
129///
130/// ```text
131/// <identifier>
132///   <key> "<value>"
133///   <key> "<value>"
134///   ...
135/// ```
136///
137/// # Arguments
138///
139/// * `id` - The object identifier (displayed on first line)
140/// * `obj` - The object as key-value pairs to format
141/// * `indent` - The indentation string (default: 2 spaces)
142///
143/// # Returns
144///
145/// Formatted indented Links Notation string, or an error
146///
147/// # Example
148///
149/// ```rust
150/// use lino_objects_codec::format::format_indented;
151/// use std::collections::HashMap;
152///
153/// let mut obj = HashMap::new();
154/// obj.insert("status".to_string(), "executed".to_string());
155/// obj.insert("exitCode".to_string(), "0".to_string());
156///
157/// let result = format_indented("my-uuid", &obj, "  ").unwrap();
158/// assert!(result.starts_with("my-uuid\n"));
159/// ```
160pub fn format_indented<S: ::std::hash::BuildHasher>(
161    id: &str,
162    obj: &HashMap<String, String, S>,
163    indent: &str,
164) -> Result<String, FormatError> {
165    if id.is_empty() {
166        return Err(FormatError::MissingField("id".to_string()));
167    }
168
169    let mut lines = vec![id.to_string()];
170
171    for (key, value) in obj {
172        let escaped_key = escape_reference(key);
173        let formatted_value = format_indented_value(value);
174        lines.push(format!("{}{} {}", indent, escaped_key, formatted_value));
175    }
176
177    Ok(lines.join("\n"))
178}
179
180/// Format an object in indented Links Notation format, maintaining key order.
181///
182/// This is similar to `format_indented` but takes a slice of tuples to preserve
183/// the order of keys.
184///
185/// # Arguments
186///
187/// * `id` - The object identifier (displayed on first line)
188/// * `pairs` - The key-value pairs in order
189/// * `indent` - The indentation string (default: 2 spaces)
190///
191/// # Returns
192///
193/// Formatted indented Links Notation string, or an error
194pub fn format_indented_ordered(
195    id: &str,
196    pairs: &[(&str, &str)],
197    indent: &str,
198) -> Result<String, FormatError> {
199    if id.is_empty() {
200        return Err(FormatError::MissingField("id".to_string()));
201    }
202
203    let mut lines = vec![id.to_string()];
204
205    for (key, value) in pairs {
206        let escaped_key = escape_reference(key);
207        let formatted_value = format_indented_value(value);
208        lines.push(format!("{}{} {}", indent, escaped_key, formatted_value));
209    }
210
211    Ok(lines.join("\n"))
212}
213
214/// Parse an indented Links Notation string back to an object.
215///
216/// This function uses the links-notation parser for proper parsing,
217/// supporting the standard Links Notation indented syntax.
218///
219/// Parses strings like:
220///
221/// ```text
222/// <identifier>
223///   <key> "<value>"
224///   <key> "<value>"
225///   ...
226/// ```
227///
228/// The format with colon after identifier is also supported (standard lino):
229///
230/// ```text
231/// <identifier>:
232///   <key> "<value>"
233/// ```
234///
235/// # Arguments
236///
237/// * `text` - The indented Links Notation string to parse
238///
239/// # Returns
240///
241/// A tuple of (id, HashMap of key-value pairs), or an error
242///
243/// # Example
244///
245/// ```rust
246/// use lino_objects_codec::format::parse_indented;
247///
248/// let text = "my-uuid\n  status \"executed\"\n  exitCode \"0\"";
249/// let (id, obj) = parse_indented(text).unwrap();
250/// assert_eq!(id, "my-uuid");
251/// assert_eq!(obj.get("status"), Some(&"executed".to_string()));
252/// ```
253pub fn parse_indented(text: &str) -> Result<(String, HashMap<String, String>), FormatError> {
254    if text.is_empty() {
255        return Err(FormatError::InvalidInput(
256            "text is required for parse_indented".to_string(),
257        ));
258    }
259
260    let lines: Vec<&str> = text.lines().collect();
261    if lines.is_empty() {
262        return Err(FormatError::InvalidInput(
263            "text must have at least one line (the identifier)".to_string(),
264        ));
265    }
266
267    // Filter out empty lines to preserve indentation structure for the parser
268    // Empty lines would break the indentation context in links-notation
269    let non_empty_lines: Vec<&str> = lines
270        .iter()
271        .filter(|l| !l.trim().is_empty())
272        .copied()
273        .collect();
274
275    if non_empty_lines.is_empty() {
276        return Err(FormatError::InvalidInput(
277            "text must have at least one non-empty line (the identifier)".to_string(),
278        ));
279    }
280
281    // Convert to standard lino format by adding colon after first line if not present
282    // This allows the links-notation parser to properly parse the indented structure
283    let first_line = non_empty_lines[0].trim();
284    let lino_text = if first_line.ends_with(':') {
285        non_empty_lines.join("\n")
286    } else {
287        format!("{}:\n{}", first_line, non_empty_lines[1..].join("\n"))
288    };
289
290    // Use links-notation parser
291    let parsed = parse_lino_to_links(&lino_text)
292        .map_err(|e| FormatError::InvalidInput(format!("Parse error: {:?}", e)))?;
293
294    if parsed.is_empty() {
295        return Err(FormatError::InvalidInput(
296            "Failed to parse indented Links Notation".to_string(),
297        ));
298    }
299
300    // Extract id and key-value pairs from parsed result
301    let main_link = &parsed[0];
302    let (result_id, values) = match main_link {
303        LiNo::Link { id, values } => (id.clone().unwrap_or_default(), values),
304        LiNo::Ref(id) => (id.clone(), &vec![]),
305    };
306
307    let mut obj = HashMap::new();
308
309    // Process the values array - each entry is a doublet (key value)
310    for child in values {
311        if let LiNo::Link {
312            values: child_values,
313            ..
314        } = child
315        {
316            if child_values.len() == 2 {
317                let key_ref = &child_values[0];
318                let value_ref = &child_values[1];
319
320                // Get key string
321                let key = match key_ref {
322                    LiNo::Ref(k) => k.clone(),
323                    LiNo::Link { id, .. } => id.clone().unwrap_or_default(),
324                };
325
326                // Get value string
327                let value = match value_ref {
328                    LiNo::Ref(v) => v.clone(),
329                    LiNo::Link { id, .. } => id.clone().unwrap_or_default(),
330                };
331
332                obj.insert(key, value);
333            }
334        }
335    }
336
337    Ok((result_id, obj))
338}