bc_envelope_pattern/
format.rs

1use bc_envelope::prelude::*;
2
3use crate::Path;
4
5/// A builder that provides formatting options for each path element.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum PathElementFormat {
8    /// Summary format, with optional maximum length for truncation.
9    Summary(Option<usize>),
10    EnvelopeUR,
11    DigestUR,
12}
13
14impl Default for PathElementFormat {
15    fn default() -> Self {
16        PathElementFormat::Summary(None)
17    }
18}
19
20/// Options for formatting paths.
21#[derive(Debug, Clone)]
22pub struct FormatPathsOpts {
23    /// Whether to indent each path element.
24    /// If true, each element will be indented by 4 spaces per level.
25    indent: bool,
26
27    /// Format for each path element.
28    /// Default is `PathElementFormat::Summary(None)`.
29    element_format: PathElementFormat,
30
31    /// If true, only the last element of each path will be formatted.
32    /// This is useful for displaying only the final destination of a path.
33    /// If false, all elements will be formatted.
34    last_element_only: bool,
35}
36
37impl Default for FormatPathsOpts {
38    /// Returns the default formatting options:
39    /// - `indent`: true
40    /// - `element_format`: PathElementFormat::Summary(None)
41    /// - `last_element_only`: false
42    fn default() -> Self {
43        Self {
44            indent: true,
45            element_format: PathElementFormat::default(),
46            last_element_only: false,
47        }
48    }
49}
50
51impl FormatPathsOpts {
52    /// Creates a new FormatPathsOpts with default values.
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Sets whether to indent each path element.
58    /// If true, each element will be indented by 4 spaces per level.
59    pub fn indent(mut self, indent: bool) -> Self {
60        self.indent = indent;
61        self
62    }
63
64    /// Sets the format for each path element.
65    /// Default is `PathElementFormat::Summary(None)`.
66    pub fn element_format(mut self, format: PathElementFormat) -> Self {
67        self.element_format = format;
68        self
69    }
70
71    /// Sets whether to format only the last element of each path.
72    /// If true, only the last element will be formatted.
73    /// If false, all elements will be formatted.
74    pub fn last_element_only(mut self, last_element_only: bool) -> Self {
75        self.last_element_only = last_element_only;
76        self
77    }
78}
79
80impl AsRef<FormatPathsOpts> for FormatPathsOpts {
81    fn as_ref(&self) -> &FormatPathsOpts {
82        self
83    }
84}
85
86pub fn envelope_summary(env: &Envelope) -> String {
87    let id = env.short_id(DigestDisplayFormat::Short);
88    let summary = match env.case() {
89        EnvelopeCase::Node { .. } => {
90            format!("NODE {}", env.format_flat())
91        }
92        EnvelopeCase::Leaf { cbor, .. } => {
93            format!(
94                "LEAF {}",
95                cbor.envelope_summary(usize::MAX, &FormatContextOpt::default())
96                    .unwrap_or_else(|_| "ERROR".to_string())
97            )
98        }
99        EnvelopeCase::Wrapped { .. } => {
100            format!("WRAPPED {}", env.format_flat())
101        }
102        EnvelopeCase::Assertion(_) => {
103            format!("ASSERTION {}", env.format_flat())
104        }
105        EnvelopeCase::Elided(_) => "ELIDED".to_string(),
106        EnvelopeCase::KnownValue { value, .. } => {
107            let content = with_format_context!(|ctx: &FormatContext| {
108                let known_value = KnownValuesStore::known_value_for_raw_value(
109                    value.value(),
110                    Some(ctx.known_values()),
111                );
112                format!("'{}'", known_value)
113            });
114            format!("KNOWN_VALUE {}", content)
115        }
116        EnvelopeCase::Encrypted(_) => "ENCRYPTED".to_string(),
117        EnvelopeCase::Compressed(_) => "COMPRESSED".to_string(),
118    };
119    format!("{} {}", id, summary)
120}
121
122/// Truncates a string to the specified maximum length, appending an ellipsis if
123/// truncated. If `max_length` is None, returns the original string.
124fn truncate_with_ellipsis(s: &str, max_length: Option<usize>) -> String {
125    match max_length {
126        Some(max_len) if s.len() > max_len => {
127            if max_len > 1 {
128                format!("{}…", &s[0..(max_len - 1)])
129            } else {
130                "…".to_string()
131            }
132        }
133        _ => s.to_string(),
134    }
135}
136
137/// Format each path element on its own line, each line successively indented by
138/// 4 spaces. Options can be provided to customize the formatting.
139pub fn format_path_opt(
140    path: &Path,
141    opts: impl AsRef<FormatPathsOpts>,
142) -> String {
143    let opts = opts.as_ref();
144
145    if opts.last_element_only {
146        // Only format the last element, no indentation.
147        if let Some(element) = path.iter().last() {
148            match opts.element_format {
149                PathElementFormat::Summary(max_length) => {
150                    let summary = envelope_summary(element);
151                    truncate_with_ellipsis(&summary, max_length)
152                }
153                PathElementFormat::EnvelopeUR => element.ur_string(),
154                PathElementFormat::DigestUR => element.digest().ur_string(),
155            }
156        } else {
157            String::new()
158        }
159    } else {
160        match opts.element_format {
161            PathElementFormat::Summary(max_length) => {
162                // Multi-line output with indentation for summaries.
163                let mut lines = Vec::new();
164                for (index, element) in path.iter().enumerate() {
165                    let indent = if opts.indent {
166                        " ".repeat(index * 4)
167                    } else {
168                        String::new()
169                    };
170
171                    let summary = envelope_summary(element);
172                    let content = truncate_with_ellipsis(&summary, max_length);
173
174                    lines.push(format!("{}{}", indent, content));
175                }
176                lines.join("\n")
177            }
178            PathElementFormat::EnvelopeUR => {
179                // Single-line, space-separated envelope URs.
180                path.iter()
181                    .map(|element| element.ur_string())
182                    .collect::<Vec<_>>()
183                    .join(" ")
184            }
185            PathElementFormat::DigestUR => {
186                // Single-line, space-separated digest URs.
187                path.iter()
188                    .map(|element| element.digest().ur_string())
189                    .collect::<Vec<_>>()
190                    .join(" ")
191            }
192        }
193    }
194}
195
196/// Format each path element on its own line, each line successively indented by
197/// 4 spaces.
198pub fn format_path(path: &Path) -> String {
199    format_path_opt(path, FormatPathsOpts::default())
200}
201
202pub fn format_paths_with_captures(
203    paths: &[Path],
204    captures: &std::collections::HashMap<String, Vec<Path>>,
205) -> String {
206    format_paths_with_captures_opt(paths, captures, FormatPathsOpts::default())
207}
208
209/// Format multiple paths with captures in a structured way.
210/// Captures come first, sorted lexicographically by name, with their name
211/// prefixed by '@'. Regular paths follow after all captures.
212pub fn format_paths_with_captures_opt(
213    paths: &[Path],
214    captures: &std::collections::HashMap<String, Vec<Path>>,
215    opts: impl AsRef<FormatPathsOpts>,
216) -> String {
217    let opts = opts.as_ref();
218    let mut result = Vec::new();
219
220    // First, format all captures, sorted lexicographically by name
221    let mut capture_names: Vec<&String> = captures.keys().collect();
222    capture_names.sort();
223
224    for capture_name in capture_names {
225        if let Some(capture_paths) = captures.get(capture_name) {
226            result.push(format!("@{}", capture_name));
227            for path in capture_paths {
228                let formatted_path = format_path_opt(path, opts);
229                // Add indentation to each line of the formatted path
230                for line in formatted_path.split('\n') {
231                    if !line.is_empty() {
232                        result.push(format!("    {}", line));
233                    }
234                }
235            }
236        }
237    }
238
239    // Then, format all regular paths
240    match opts.element_format {
241        PathElementFormat::EnvelopeUR | PathElementFormat::DigestUR => {
242            // For UR formats, join paths with spaces on same line
243            if !paths.is_empty() {
244                let formatted_paths = paths
245                    .iter()
246                    .map(|path| format_path_opt(path, opts))
247                    .collect::<Vec<_>>()
248                    .join(" ");
249                if !formatted_paths.is_empty() {
250                    result.push(formatted_paths);
251                }
252            }
253        }
254        PathElementFormat::Summary(_) => {
255            // For summary format, format each path separately
256            for path in paths {
257                let formatted_path = format_path_opt(path, opts);
258                for line in formatted_path.split('\n') {
259                    if !line.is_empty() {
260                        result.push(line.to_string());
261                    }
262                }
263            }
264        }
265    }
266
267    result.join("\n")
268}
269
270/// Format multiple paths with custom formatting options.
271pub fn format_paths_opt(
272    paths: &[Path],
273    opts: impl AsRef<FormatPathsOpts>,
274) -> String {
275    // Call format_paths_with_captures with empty captures
276    format_paths_with_captures_opt(
277        paths,
278        &std::collections::HashMap::new(),
279        opts,
280    )
281}
282
283/// Format multiple paths with default options.
284pub fn format_paths(paths: &[Path]) -> String {
285    format_paths_opt(paths, FormatPathsOpts::default())
286}
287
288#[cfg(test)]
289mod tests {
290    use std::collections::HashMap;
291
292    use bc_envelope::prelude::*;
293    use indoc::indoc;
294
295    use super::*;
296
297    fn create_test_path() -> Path {
298        vec![
299            Envelope::new(42),
300            Envelope::new("test"),
301            Envelope::new(vec![1, 2, 3]),
302        ]
303    }
304
305    #[test]
306    fn test_format_path_default() {
307        let path = create_test_path();
308        let actual = format_path(&path);
309
310        #[rustfmt::skip]
311        let expected = indoc! {r#"
312            7f83f7bd LEAF 42
313                6fe3180f LEAF "test"
314                    4abc3113 LEAF [1, 2, 3]
315        "#}.trim();
316
317        assert_eq!(actual, expected, "format_path with default options");
318    }
319
320    #[test]
321    fn test_format_path_last_element_only() {
322        let path = create_test_path();
323        let opts = FormatPathsOpts::new().last_element_only(true);
324        let actual = format_path_opt(&path, opts);
325
326        #[rustfmt::skip]
327        let expected = indoc! {r#"
328            4abc3113 LEAF [1, 2, 3]
329        "#}.trim();
330
331        assert_eq!(actual, expected, "format_path with last_element_only");
332    }
333
334    #[test]
335    fn test_format_paths_multiple() {
336        let path1 = vec![Envelope::new(1)];
337        let path2 = vec![Envelope::new(2)];
338        let paths = vec![path1, path2];
339
340        let actual = format_paths(&paths);
341
342        #[rustfmt::skip]
343        let expected = indoc! {r#"
344            4bf5122f LEAF 1
345            dbc1b4c9 LEAF 2
346        "#}.trim();
347
348        assert_eq!(actual, expected, "format_paths with multiple paths");
349    }
350
351    #[test]
352    fn test_format_paths_with_captures() {
353        let path1 = vec![Envelope::new(1)];
354        let path2 = vec![Envelope::new(2)];
355        let paths = vec![path1.clone(), path2.clone()];
356
357        let mut captures = HashMap::new();
358        captures.insert("capture1".to_string(), vec![path1]);
359        captures.insert("capture2".to_string(), vec![path2]);
360
361        let actual = format_paths_with_captures_opt(
362            &paths,
363            &captures,
364            FormatPathsOpts::default(),
365        );
366
367        #[rustfmt::skip]
368        let expected = indoc! {r#"
369            @capture1
370                4bf5122f LEAF 1
371            @capture2
372                dbc1b4c9 LEAF 2
373            4bf5122f LEAF 1
374            dbc1b4c9 LEAF 2
375        "#}.trim();
376
377        assert_eq!(
378            actual, expected,
379            "format_paths_with_captures with sorted captures"
380        );
381    }
382
383    #[test]
384    fn test_format_paths_with_empty_captures() {
385        let path1 = vec![Envelope::new(1)];
386        let path2 = vec![Envelope::new(2)];
387        let paths = vec![path1, path2];
388
389        let captures = HashMap::new();
390        let formatted = format_paths_with_captures_opt(
391            &paths,
392            &captures,
393            FormatPathsOpts::default(),
394        );
395
396        // Should be same as format_paths when no captures
397        let expected = format_paths(&paths);
398        assert_eq!(formatted, expected);
399    }
400
401    #[test]
402    fn test_capture_names_sorted() {
403        let path1 = vec![Envelope::new(1)];
404        let path2 = vec![Envelope::new(2)];
405        let path3 = vec![Envelope::new(3)];
406        let paths = vec![];
407
408        let mut captures = HashMap::new();
409        captures.insert("zebra".to_string(), vec![path1]);
410        captures.insert("alpha".to_string(), vec![path2]);
411        captures.insert("beta".to_string(), vec![path3]);
412
413        let actual = format_paths_with_captures_opt(
414            &paths,
415            &captures,
416            FormatPathsOpts::default(),
417        );
418
419        #[rustfmt::skip]
420        let expected = indoc! {r#"
421            @alpha
422                dbc1b4c9 LEAF 2
423            @beta
424                084fed08 LEAF 3
425            @zebra
426                4bf5122f LEAF 1
427        "#}.trim();
428
429        assert_eq!(
430            actual, expected,
431            "capture names should be sorted lexicographically"
432        );
433    }
434
435    #[test]
436    fn test_format_paths_with_captures_envelope_ur() {
437        bc_components::register_tags();
438
439        let path1 = vec![Envelope::new(1)];
440        let path2 = vec![Envelope::new(2)];
441        let paths = vec![path1.clone(), path2.clone()];
442
443        let mut captures = HashMap::new();
444        captures.insert("capture1".to_string(), vec![path1]);
445
446        let opts = FormatPathsOpts::new()
447            .element_format(PathElementFormat::EnvelopeUR);
448
449        let actual = format_paths_with_captures_opt(&paths, &captures, opts);
450
451        // For this test, we need to check the structure but URs are long and
452        // variable So we'll verify the structure exists
453        assert!(actual.contains("@capture1"));
454        assert!(actual.contains("ur:envelope"));
455
456        // Count the number of ur:envelope occurrences (should be 3: 1 capture +
457        // 2 regular paths)
458        let ur_count = actual.matches("ur:envelope").count();
459        assert_eq!(ur_count, 3, "Should have 3 envelope URs total");
460    }
461
462    #[test]
463    fn test_format_paths_with_captures_digest_ur() {
464        bc_components::register_tags();
465
466        let path1 = vec![Envelope::new(1)];
467        let path2 = vec![Envelope::new(2)];
468        let paths = vec![path1.clone(), path2.clone()];
469
470        let mut captures = HashMap::new();
471        captures.insert("capture1".to_string(), vec![path1]);
472
473        let opts =
474            FormatPathsOpts::new().element_format(PathElementFormat::DigestUR);
475
476        let actual = format_paths_with_captures_opt(&paths, &captures, opts);
477
478        // For this test, we need to check the structure but digest URs are also
479        // variable
480        assert!(actual.contains("@capture1"));
481        assert!(actual.contains("ur:digest"));
482
483        // Count the number of ur:digest occurrences (should be 3: 1 capture + 2
484        // regular paths)
485        let ur_count = actual.matches("ur:digest").count();
486        assert_eq!(ur_count, 3, "Should have 3 digest URs total");
487    }
488
489    #[test]
490    fn test_format_paths_with_captures_no_indent() {
491        let path1 = vec![Envelope::new(1)];
492        let paths = vec![path1.clone()];
493
494        let mut captures = HashMap::new();
495        captures.insert("capture1".to_string(), vec![path1]);
496
497        let opts = FormatPathsOpts::new().indent(false);
498
499        let actual = format_paths_with_captures_opt(&paths, &captures, opts);
500
501        #[rustfmt::skip]
502        let expected = indoc! {r#"
503            @capture1
504                4bf5122f LEAF 1
505            4bf5122f LEAF 1
506        "#}.trim();
507
508        assert_eq!(
509            actual, expected,
510            "captures should still have fixed indentation even with indent=false"
511        );
512    }
513
514    #[test]
515    fn test_truncate_with_ellipsis() {
516        assert_eq!(truncate_with_ellipsis("hello", None), "hello");
517        assert_eq!(truncate_with_ellipsis("hello", Some(10)), "hello");
518        assert_eq!(truncate_with_ellipsis("hello world", Some(5)), "hell…");
519        assert_eq!(truncate_with_ellipsis("hello", Some(1)), "…");
520    }
521}