Skip to main content

switchback_traits/
layout_paths.rs

1//! Layout-aware output paths (protobuf-mdbook parity).
2
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6use crate::options::Layout;
7
8/// Protobuf entity kind for layout path indexing.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum ProtobufEntityKind {
11    /// Protobuf message type.
12    Message,
13    /// Protobuf enum type.
14    Enum,
15    /// Protobuf service definition.
16    Service,
17}
18
19/// Layout entity key for path indexing (protobuf package + kind + name).
20#[derive(Clone, Debug, PartialEq, Eq, Hash)]
21pub struct LayoutEntityKey {
22    /// Protobuf package / group id string.
23    pub package: String,
24    /// Message, enum, or service kind.
25    pub kind: ProtobufEntityKind,
26    /// Entity name within the package.
27    pub name: String,
28}
29
30/// Relative path to a package rollup page under `markdown_root`.
31pub fn package_page_rel(markdown_root: &str, package: &str) -> PathBuf {
32    PathBuf::from(format!("{markdown_root}/{package}.md"))
33}
34
35/// Relative path to a package index page (entity/split layouts).
36pub fn package_index_rel(layout: Layout, markdown_root: &str, package: &str) -> PathBuf {
37    match layout {
38        Layout::Package => package_page_rel(markdown_root, package),
39        Layout::Entity | Layout::Split => PathBuf::from(format!(
40            "{markdown_root}/{}/index.md",
41            package.replace('.', "/")
42        )),
43    }
44}
45
46/// Relative entity page path for entity/split layouts.
47pub fn layout_entity_rel_path(
48    layout: Layout,
49    markdown_root: &str,
50    key: &LayoutEntityKey,
51) -> PathBuf {
52    let pkg_file = key.package.replace('.', "/");
53    match layout {
54        Layout::Package => package_page_rel(markdown_root, &key.package),
55        Layout::Entity | Layout::Split => PathBuf::from(match key.kind {
56            ProtobufEntityKind::Message => {
57                format!("{markdown_root}/{pkg_file}/messages/{}.md", key.name)
58            }
59            ProtobufEntityKind::Enum => {
60                format!("{markdown_root}/{pkg_file}/enums/{}.md", key.name)
61            }
62            ProtobufEntityKind::Service => {
63                format!("{markdown_root}/{pkg_file}/services/{}.md", key.name)
64            }
65        }),
66    }
67}
68
69/// Heading anchor id compatible with mdBook HTML output.
70pub fn heading_slug(name: &str) -> String {
71    id_from_content(name)
72}
73
74/// Assigns mdBook-style unique heading ids in document order (appends `-1`, `-2`, … on collision).
75pub fn unique_heading_ids(titles: impl IntoIterator<Item = impl AsRef<str>>) -> Vec<String> {
76    let mut used = HashSet::new();
77    titles
78        .into_iter()
79        .map(|title| {
80            let base = id_from_content(title.as_ref());
81            unique_id(&base, &mut used)
82        })
83        .collect()
84}
85
86fn unique_id(id: &str, used: &mut HashSet<String>) -> String {
87    if used.insert(id.to_string()) {
88        return id.to_string();
89    }
90    let mut counter = 1u32;
91    loop {
92        let candidate = format!("{id}-{counter}");
93        if used.insert(candidate.clone()) {
94            return candidate;
95        }
96        counter += 1;
97    }
98}
99
100/// Relative POSIX path from `from_dir` to `target` (mdBook link form).
101pub fn relative_path_from_dir(from_dir: &Path, target: &Path) -> String {
102    let from_parts: Vec<_> = from_dir.components().collect();
103    let target_parts: Vec<_> = target.components().collect();
104    let mut i = 0;
105    while i < from_parts.len() && i < target_parts.len() && from_parts[i] == target_parts[i] {
106        i += 1;
107    }
108    let ups = from_parts.len().saturating_sub(i);
109    let mut parts: Vec<String> = (0..ups).map(|_| "..".to_string()).collect();
110    for c in &target_parts[i..] {
111        parts.push(c.as_os_str().to_string_lossy().into_owned());
112    }
113    let raw = if parts.is_empty() {
114        target
115            .file_name()
116            .unwrap_or_default()
117            .to_string_lossy()
118            .into_owned()
119    } else {
120        parts.join("/")
121    };
122    encode_markdown_link_path(&raw)
123}
124
125/// Percent-encode a relative path for use inside Markdown `](...)` link targets.
126///
127/// mdBook and CommonMark treat spaces as end-of-URL; encode every segment so paths
128/// like `operations/GET -board.md` resolve correctly.
129pub fn encode_markdown_link_path(path: &str) -> String {
130    path.split('/')
131        .map(encode_path_segment)
132        .collect::<Vec<_>>()
133        .join("/")
134}
135
136/// Decode a percent-encoded Markdown link path back to a filesystem path.
137pub fn decode_markdown_link_path(path: &str) -> String {
138    let mut out = String::new();
139    let bytes = path.as_bytes();
140    let mut i = 0;
141    while i < bytes.len() {
142        if bytes[i] == b'%'
143            && i + 2 < bytes.len()
144            && let Ok(s) = std::str::from_utf8(&bytes[i + 1..i + 3])
145            && let Ok(byte) = u8::from_str_radix(s, 16)
146        {
147            out.push(byte as char);
148            i += 3;
149            continue;
150        }
151        out.push(bytes[i] as char);
152        i += 1;
153    }
154    out
155}
156
157fn encode_path_segment(segment: &str) -> String {
158    let mut out = String::new();
159    for ch in segment.chars() {
160        match ch {
161            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(ch),
162            _ => {
163                for b in ch.to_string().as_bytes() {
164                    out.push_str(&format!("%{b:02X}"));
165                }
166            }
167        }
168    }
169    out
170}
171
172fn id_from_content(content: &str) -> String {
173    content
174        .trim()
175        .to_lowercase()
176        .chars()
177        .filter_map(|ch| {
178            if ch.is_alphanumeric() || ch == '_' || ch == '-' {
179                Some(ch)
180            } else if ch.is_whitespace() {
181                Some('-')
182            } else {
183                None
184            }
185        })
186        .collect()
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn mdbook_slug_for_pascal_case_message() {
195        assert_eq!(
196            heading_slug("GetOrganizationsResponse"),
197            "getorganizationsresponse"
198        );
199    }
200
201    #[test]
202    fn unique_heading_ids_deduplicates_collisions() {
203        let ids = unique_heading_ids(["EchoRequest", "EchoResponse", "EchoRequest"]);
204        assert_eq!(ids, ["echorequest", "echoresponse", "echorequest-1"]);
205    }
206
207    #[test]
208    fn id_from_content_matches_mdbook_behavior() {
209        let cases = [
210            ("GetOrganizationsResponse", "getorganizationsresponse"),
211            ("中文標題 CJK title", "中文標題-cjk-title"),
212            ("_-_12345", "_-_12345"),
213        ];
214        for (input, expected) in cases {
215            assert_eq!(id_from_content(input), expected, "input: {input:?}");
216        }
217    }
218
219    #[test]
220    fn encode_markdown_link_path_spaces() {
221        assert_eq!(
222            encode_markdown_link_path("operations/GET -board.md"),
223            "operations/GET%20-board.md"
224        );
225    }
226
227    #[test]
228    fn decode_markdown_link_path_roundtrip() {
229        let encoded = encode_markdown_link_path("operations/PUT -board-{row}-{column}.md");
230        assert_eq!(
231            decode_markdown_link_path(&encoded),
232            "operations/PUT -board-{row}-{column}.md"
233        );
234    }
235
236    #[test]
237    fn entity_split_path() {
238        let key = LayoutEntityKey {
239            package: "acme.v1".into(),
240            kind: ProtobufEntityKind::Message,
241            name: "Pet".into(),
242        };
243        assert_eq!(
244            layout_entity_rel_path(Layout::Entity, "src/packages", &key),
245            PathBuf::from("src/packages/acme/v1/messages/Pet.md")
246        );
247    }
248}