use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::options::Layout;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ProtobufEntityKind {
Message,
Enum,
Service,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct LayoutEntityKey {
pub package: String,
pub kind: ProtobufEntityKind,
pub name: String,
}
pub fn package_page_rel(markdown_root: &str, package: &str) -> PathBuf {
PathBuf::from(format!("{markdown_root}/{package}.md"))
}
pub fn package_index_rel(layout: Layout, markdown_root: &str, package: &str) -> PathBuf {
match layout {
Layout::Package => package_page_rel(markdown_root, package),
Layout::Entity | Layout::Split => PathBuf::from(format!(
"{markdown_root}/{}/index.md",
package.replace('.', "/")
)),
}
}
pub fn layout_entity_rel_path(
layout: Layout,
markdown_root: &str,
key: &LayoutEntityKey,
) -> PathBuf {
let pkg_file = key.package.replace('.', "/");
match layout {
Layout::Package => package_page_rel(markdown_root, &key.package),
Layout::Entity | Layout::Split => PathBuf::from(match key.kind {
ProtobufEntityKind::Message => {
format!("{markdown_root}/{pkg_file}/messages/{}.md", key.name)
}
ProtobufEntityKind::Enum => {
format!("{markdown_root}/{pkg_file}/enums/{}.md", key.name)
}
ProtobufEntityKind::Service => {
format!("{markdown_root}/{pkg_file}/services/{}.md", key.name)
}
}),
}
}
pub fn heading_slug(name: &str) -> String {
id_from_content(name)
}
pub fn unique_heading_ids(titles: impl IntoIterator<Item = impl AsRef<str>>) -> Vec<String> {
let mut used = HashSet::new();
titles
.into_iter()
.map(|title| {
let base = id_from_content(title.as_ref());
unique_id(&base, &mut used)
})
.collect()
}
fn unique_id(id: &str, used: &mut HashSet<String>) -> String {
if used.insert(id.to_string()) {
return id.to_string();
}
let mut counter = 1u32;
loop {
let candidate = format!("{id}-{counter}");
if used.insert(candidate.clone()) {
return candidate;
}
counter += 1;
}
}
pub fn relative_path_from_dir(from_dir: &Path, target: &Path) -> String {
let from_parts: Vec<_> = from_dir.components().collect();
let target_parts: Vec<_> = target.components().collect();
let mut i = 0;
while i < from_parts.len() && i < target_parts.len() && from_parts[i] == target_parts[i] {
i += 1;
}
let ups = from_parts.len().saturating_sub(i);
let mut parts: Vec<String> = (0..ups).map(|_| "..".to_string()).collect();
for c in &target_parts[i..] {
parts.push(c.as_os_str().to_string_lossy().into_owned());
}
let raw = if parts.is_empty() {
target
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
} else {
parts.join("/")
};
encode_markdown_link_path(&raw)
}
pub fn encode_markdown_link_path(path: &str) -> String {
path.split('/')
.map(encode_path_segment)
.collect::<Vec<_>>()
.join("/")
}
pub fn decode_markdown_link_path(path: &str) -> String {
let mut out = String::new();
let bytes = path.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let Ok(s) = std::str::from_utf8(&bytes[i + 1..i + 3])
&& let Ok(byte) = u8::from_str_radix(s, 16)
{
out.push(byte as char);
i += 3;
continue;
}
out.push(bytes[i] as char);
i += 1;
}
out
}
fn encode_path_segment(segment: &str) -> String {
let mut out = String::new();
for ch in segment.chars() {
match ch {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(ch),
_ => {
for b in ch.to_string().as_bytes() {
out.push_str(&format!("%{b:02X}"));
}
}
}
}
out
}
fn id_from_content(content: &str) -> String {
content
.trim()
.to_lowercase()
.chars()
.filter_map(|ch| {
if ch.is_alphanumeric() || ch == '_' || ch == '-' {
Some(ch)
} else if ch.is_whitespace() {
Some('-')
} else {
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mdbook_slug_for_pascal_case_message() {
assert_eq!(
heading_slug("GetOrganizationsResponse"),
"getorganizationsresponse"
);
}
#[test]
fn unique_heading_ids_deduplicates_collisions() {
let ids = unique_heading_ids(["EchoRequest", "EchoResponse", "EchoRequest"]);
assert_eq!(ids, ["echorequest", "echoresponse", "echorequest-1"]);
}
#[test]
fn id_from_content_matches_mdbook_behavior() {
let cases = [
("GetOrganizationsResponse", "getorganizationsresponse"),
("中文標題 CJK title", "中文標題-cjk-title"),
("_-_12345", "_-_12345"),
];
for (input, expected) in cases {
assert_eq!(id_from_content(input), expected, "input: {input:?}");
}
}
#[test]
fn encode_markdown_link_path_spaces() {
assert_eq!(
encode_markdown_link_path("operations/GET -board.md"),
"operations/GET%20-board.md"
);
}
#[test]
fn decode_markdown_link_path_roundtrip() {
let encoded = encode_markdown_link_path("operations/PUT -board-{row}-{column}.md");
assert_eq!(
decode_markdown_link_path(&encoded),
"operations/PUT -board-{row}-{column}.md"
);
}
#[test]
fn entity_split_path() {
let key = LayoutEntityKey {
package: "acme.v1".into(),
kind: ProtobufEntityKind::Message,
name: "Pet".into(),
};
assert_eq!(
layout_entity_rel_path(Layout::Entity, "src/packages", &key),
PathBuf::from("src/packages/acme/v1/messages/Pet.md")
);
}
}