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}