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}