Skip to main content

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