switchback_traits/
layout_paths.rs1use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6use crate::options::Layout;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum ProtobufEntityKind {
11 Message,
13 Enum,
15 Service,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq, Hash)]
21pub struct LayoutEntityKey {
22 pub package: String,
24 pub kind: ProtobufEntityKind,
26 pub name: String,
28}
29
30pub fn package_page_rel(markdown_root: &str, package: &str) -> PathBuf {
32 PathBuf::from(format!("{markdown_root}/{package}.md"))
33}
34
35pub 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
46pub 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
69pub fn heading_slug(name: &str) -> String {
71 id_from_content(name)
72}
73
74pub 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
100pub 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
125pub fn encode_markdown_link_path(path: &str) -> String {
130 path.split('/')
131 .map(encode_path_segment)
132 .collect::<Vec<_>>()
133 .join("/")
134}
135
136pub 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}