Skip to main content

binoc_sdk/
path.rs

1//! Logical-path construction and decomposition.
2//!
3//! Binoc logical paths use two separators with distinct meaning (see the
4//! parsed-children ADR, `docs/adr/2026-06-14-parsed_children_and_decompose_boundaries.md`):
5//!
6//! - [`MEMBER_SEP`] (`/`) — **membership**: structure that already existed as a
7//!   navigable tree (directory entries, paths *inside* an extracted archive). No
8//!   format had to be decoded to reveal it.
9//! - [`DECOMPOSE_SEP`] (`/>`) — **decompose boundary**: a node binoc had to open
10//!   a format to reveal (the immediate members of an archive expansion, and every
11//!   parsed table / sheet / section).
12//!
13//! Segment names beginning with `>` are escaped as `\>` so an ordinary member
14//! named `>q1.csv` is written `dir/\>q1.csv`, not `dir/>q1.csv`.
15//!
16//! The separators are cosmetic: nothing in the engine decides behavior by parsing
17//! a path string. Parent/child relationships come from the IR tree, and child
18//! kind rides on `ItemRef.projection_hint.item_type`. These helpers exist so that
19//! the few places that *do* need to construct or walk a path (expansion,
20//! container parsers, projection nesting) share one implementation instead of
21//! hand-rolling `format!`/`split` calls.
22
23/// Separator between members of an already-navigable container.
24pub const MEMBER_SEP: char = '/';
25
26/// Marker introducing a decompose-boundary child (a node revealed by opening a
27/// format). Two characters: a slash followed by `>`.
28pub const DECOMPOSE_SEP: &str = "/>";
29
30/// Escape a logical path segment for inclusion after either separator.
31///
32/// The escape is intentionally tiny: only leading `>` and leading `\` are
33/// rewritten, because only the first byte after `/` participates in the
34/// decompose-boundary grammar. A literal leading `>` becomes `\>`; a literal
35/// leading `\` becomes `\\` so the escaped spelling itself can round-trip.
36pub fn escape_segment(name: &str) -> String {
37    if name.starts_with(['>', '\\']) {
38        format!("\\{name}")
39    } else {
40        name.to_string()
41    }
42}
43
44/// Append `name` as an ordinary member of `parent` (the `/` separator).
45///
46/// Used for directory entries and for structure inside an extracted archive.
47/// An empty `parent` yields `name` unchanged (root-level entries).
48pub fn member_child(parent: &str, name: &str) -> String {
49    let name = escape_segment(name);
50    if parent.is_empty() {
51        name
52    } else {
53        format!("{parent}{MEMBER_SEP}{name}")
54    }
55}
56
57/// Append `name` as a decompose-boundary child of `parent` (the `/>` separator).
58///
59/// Used for the immediate members of an archive expansion and for parsed
60/// table/sheet/section children. `parent` is never meaningfully empty here — a
61/// decompose child always hangs off the node whose format was opened.
62pub fn decompose_child(parent: &str, name: &str) -> String {
63    let name = escape_segment(name);
64    format!("{parent}{DECOMPOSE_SEP}{name}")
65}
66
67/// The final segment of a logical path, after the last separator of either kind.
68pub fn file_name(path: &str) -> &str {
69    match path.rfind(MEMBER_SEP) {
70        // A `/` immediately followed by `>` is a decompose boundary; the name
71        // starts after the `>`. Otherwise it is an ordinary member separator.
72        Some(slash) if path[slash + 1..].starts_with('>') => &path[slash + 2..],
73        Some(slash) => &path[slash + 1..],
74        None => path,
75    }
76}
77
78/// Walk `path`, yielding `(cumulative_path, segment_name)` for each segment in
79/// order. The cumulative path preserves the original separators, so it matches
80/// node paths constructed with [`member_child`]/[`decompose_child`] and can be
81/// used directly as a projection node key.
82///
83/// Empty segments (from leading/trailing/doubled separators) are skipped.
84pub fn segments(path: &str) -> Vec<(&str, &str)> {
85    let bytes = path.as_bytes();
86    let mut out = Vec::new();
87    let mut seg_start = 0;
88    let mut i = 0;
89    while i < bytes.len() {
90        if bytes[i] == b'/' {
91            if i > seg_start {
92                out.push((&path[..i], &path[seg_start..i]));
93            }
94            // Consume the separator: `/>` (decompose) or `/` (member).
95            if bytes.get(i + 1) == Some(&b'>') {
96                i += 2;
97            } else {
98                i += 1;
99            }
100            seg_start = i;
101        } else {
102            i += 1;
103        }
104    }
105    if seg_start < bytes.len() {
106        out.push((path, &path[seg_start..]));
107    }
108    out
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn member_and_decompose_construction() {
117        assert_eq!(member_child("", "a.txt"), "a.txt");
118        assert_eq!(member_child("dir", "a.txt"), "dir/a.txt");
119        assert_eq!(decompose_child("data.zip", "inner"), "data.zip/>inner");
120        assert_eq!(decompose_child("data.csv", "table_1"), "data.csv/>table_1");
121    }
122
123    #[test]
124    fn segment_escape_disambiguates_leading_decompose_marker() {
125        assert_eq!(escape_segment(">q1.csv"), r"\>q1.csv");
126        assert_eq!(escape_segment(r"\>q1.csv"), r"\\>q1.csv");
127        assert_eq!(member_child("dir", ">q1.csv"), r"dir/\>q1.csv");
128        assert_eq!(member_child("dir", r"\>q1.csv"), r"dir/\\>q1.csv");
129        assert_eq!(
130            decompose_child("book.xlsx", ">Summary"),
131            r"book.xlsx/>\>Summary"
132        );
133    }
134
135    #[test]
136    fn file_name_handles_both_separators() {
137        assert_eq!(file_name("a.txt"), "a.txt");
138        assert_eq!(file_name("dir/a.txt"), "a.txt");
139        assert_eq!(file_name("data.csv/>table_1"), "table_1");
140        assert_eq!(file_name(r"dir/\>q1.csv"), r"\>q1.csv");
141        assert_eq!(file_name("dir/data.zip/>reports/q1.csv"), "q1.csv");
142        assert_eq!(
143            file_name("dir/data.zip/>reports/q1.csv/>table_2"),
144            "table_2"
145        );
146    }
147
148    #[test]
149    fn segments_preserve_separators_in_cumulative_paths() {
150        assert_eq!(segments("a.txt"), vec![("a.txt", "a.txt")]);
151        assert_eq!(
152            segments("dir/a.txt"),
153            vec![("dir", "dir"), ("dir/a.txt", "a.txt")]
154        );
155        assert_eq!(
156            segments("data.csv/>table_1"),
157            vec![("data.csv", "data.csv"), ("data.csv/>table_1", "table_1")]
158        );
159        assert_eq!(
160            segments(r"dir/\>q1.csv"),
161            vec![("dir", "dir"), (r"dir/\>q1.csv", r"\>q1.csv")]
162        );
163        assert_eq!(
164            segments("dir/data.zip/>reports/q1.csv/>table_2"),
165            vec![
166                ("dir", "dir"),
167                ("dir/data.zip", "data.zip"),
168                ("dir/data.zip/>reports", "reports"),
169                ("dir/data.zip/>reports/q1.csv", "q1.csv"),
170                ("dir/data.zip/>reports/q1.csv/>table_2", "table_2"),
171            ]
172        );
173    }
174
175    #[test]
176    fn segments_skip_empty() {
177        assert_eq!(segments(""), Vec::<(&str, &str)>::new());
178        assert_eq!(segments("/"), Vec::<(&str, &str)>::new());
179        assert_eq!(segments("a//b"), vec![("a", "a"), ("a//b", "b")]);
180    }
181}