use std::path::Path;
pub fn extract_title(markdown: &str, source_path: &Path) -> String {
let fallback = source_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled")
.to_string();
for line in markdown.lines() {
let trimmed = line.trim();
if let Some(heading) = trimmed.strip_prefix("# ") {
if !heading.starts_with('#') {
let title = heading.trim().to_string();
if !title.is_empty() {
return title;
}
}
}
}
for line in markdown.lines() {
let trimmed = line.trim();
if let Some(heading) = trimmed.strip_prefix("## ") {
if !heading.starts_with('#') {
let title = heading.trim().to_string();
if !title.is_empty() {
return title;
}
}
}
}
for line in markdown.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') {
let candidate = trimmed.to_string();
if candidate.len() <= 120 {
return candidate;
}
}
}
fallback
}
pub fn title_to_filename(title: &str) -> String {
let cleaned: String = title
.chars()
.flat_map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' {
c.to_lowercase().collect::<Vec<_>>()
} else {
vec![' ']
}
})
.collect();
let parts: Vec<&str> = cleaned.split_whitespace().collect();
if parts.is_empty() {
return "untitled.md".to_string();
}
let kebab = parts.join("-");
let kebab = kebab.trim_matches('-');
format!("{}.md", kebab)
}
pub fn resolve_conflict(filename: &str, source_path: &Path, output_dir: &Path) -> String {
let path = output_dir.join(filename);
if !path.exists() {
return filename.to_string();
}
let stem = filename.trim_end_matches(".md");
let source_stem = source_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_lowercase();
let source_stem_clean: String = source_stem
.chars()
.filter(|c| c.is_alphanumeric())
.collect();
if !source_stem_clean.is_empty() {
let candidate = format!("{}-{}.md", stem, source_stem_clean);
if !output_dir.join(&candidate).exists() {
return candidate;
}
}
for i in 2..=100 {
let candidate = format!("{}-{}.md", stem, i);
if !output_dir.join(&candidate).exists() {
return candidate;
}
}
format!("{}-{:08x}.md", stem, rand::random::<u32>())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_title_to_filename_basic() {
assert_eq!(
title_to_filename("AVEVA MES Client User Guide"),
"aveva-mes-client-user-guide.md"
);
}
#[test]
fn test_title_to_filename_with_parens() {
assert_eq!(
title_to_filename("Historian Admin Guide (v2024)"),
"historian-admin-guide-v2024.md"
);
}
#[test]
fn test_title_to_filename_special_chars() {
assert_eq!(
title_to_filename("User's Guide: Installation & Setup"),
"user-s-guide-installation-setup.md"
);
}
#[test]
fn test_title_to_filename_empty() {
assert_eq!(title_to_filename(""), "untitled.md");
}
#[test]
fn test_title_to_filename_already_kebab() {
assert_eq!(
title_to_filename("already-kebab-case"),
"already-kebab-case.md"
);
}
#[test]
fn test_title_to_filename_unicode() {
assert_eq!(title_to_filename("Über Handbuch"), "über-handbuch.md");
assert_eq!(title_to_filename("Ångström Guide"), "ångström-guide.md");
}
#[test]
fn test_extract_title_h1() {
let md = "# My Document\n\nSome content.";
assert_eq!(extract_title(md, Path::new("doc.pdf")), "My Document");
}
#[test]
fn test_extract_title_h2_fallback() {
let md = "Some preamble\n## Getting Started\n\nContent.";
assert_eq!(extract_title(md, Path::new("doc.pdf")), "Getting Started");
}
#[test]
fn test_extract_title_filename_fallback() {
let md = "";
assert_eq!(
extract_title(md, Path::new("HistorianAdmin.pdf")),
"HistorianAdmin"
);
}
#[test]
fn test_extract_title_first_line_fallback() {
let md = "AVEVA Historian Administration Guide\n\nMore content";
assert_eq!(
extract_title(md, Path::new("doc.pdf")),
"AVEVA Historian Administration Guide"
);
}
#[test]
fn test_resolve_conflict_no_collision() {
let dir = tempfile::tempdir().unwrap();
let result = resolve_conflict("test.md", Path::new("doc.pdf"), dir.path());
assert_eq!(result, "test.md");
}
#[test]
fn test_resolve_conflict_with_collision() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("test.md"), "existing").unwrap();
let result = resolve_conflict("test.md", Path::new("MyDoc.pdf"), dir.path());
assert_eq!(result, "test-mydoc.md");
}
#[test]
fn test_resolve_conflict_numeric_fallback() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("test.md"), "existing").unwrap();
std::fs::write(dir.path().join("test-mydoc.md"), "existing").unwrap();
let result = resolve_conflict("test.md", Path::new("MyDoc.pdf"), dir.path());
assert_eq!(result, "test-2.md");
}
}