use crate::workspace::FilenameStyle;
const FS_ILLEGAL_CHARS: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
fn is_control_char(c: char) -> bool {
c <= '\x1F' || c == '\x7F'
}
fn is_non_portable_char(c: char) -> bool {
FS_ILLEGAL_CHARS.contains(&c) || is_control_char(c)
}
const BOUNDARY_CHARS: &[char] = &['.', '~', ' ', '\t'];
pub fn prettify_filename(filename: &str) -> String {
filename
.replace(['-', '_'], " ")
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn slugify(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
pub fn slugify_title(title: &str) -> String {
let slug = slugify(title);
if slug.is_empty() {
"untitled.md".to_string()
} else {
format!("{}.md", slug)
}
}
pub fn apply_filename_style(title: &str, style: &FilenameStyle) -> String {
match style {
FilenameStyle::Preserve => {
let cleaned: String = title
.chars()
.filter(|c| !is_non_portable_char(*c))
.collect();
let trimmed = cleaned.trim();
if trimmed.is_empty() {
"Untitled".to_string()
} else {
trimmed.to_string()
}
}
FilenameStyle::KebabCase => slugify(title),
FilenameStyle::SnakeCase => {
let result: String = title
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
let collapsed: String = result
.split('_')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("_");
if collapsed.is_empty() {
"untitled".to_string()
} else {
collapsed
}
}
FilenameStyle::ScreamingSnakeCase => {
let result: String = title
.to_uppercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
let collapsed: String = result
.split('_')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("_");
if collapsed.is_empty() {
"UNTITLED".to_string()
} else {
collapsed
}
}
}
}
#[allow(dead_code)]
pub fn slugify_title_with_style(title: &str, style: &FilenameStyle) -> String {
let stem = apply_filename_style(title, style);
format!("{}.md", stem)
}
pub fn has_non_portable_chars(filename: &str) -> Option<String> {
let stem = filename.strip_suffix(".md").unwrap_or(filename);
for c in stem.chars() {
if FS_ILLEGAL_CHARS.contains(&c) {
return Some(format!("contains '{}'", c));
}
if is_control_char(c) {
return Some(format!("contains control character U+{:04X}", c as u32));
}
}
if let Some(first) = stem.chars().next()
&& BOUNDARY_CHARS.contains(&first)
{
return Some(format!("starts with '{}'", first));
}
if let Some(last) = stem.chars().last()
&& BOUNDARY_CHARS.contains(&last)
{
return Some(format!("ends with '{}'", last));
}
None
}
pub fn sanitize_filename(filename: &str) -> String {
let (stem, ext) = if let Some(s) = filename.strip_suffix(".md") {
(s, ".md")
} else {
(filename, "")
};
let cleaned: String = stem.chars().filter(|c| !is_non_portable_char(*c)).collect();
let trimmed = cleaned
.trim_start_matches(|c: char| BOUNDARY_CHARS.contains(&c))
.trim_end_matches(|c: char| BOUNDARY_CHARS.contains(&c));
if trimmed.is_empty() {
format!("Untitled{}", ext)
} else {
format!("{}{}", trimmed, ext)
}
}
pub fn extract_first_line_h1(content: &str) -> Option<String> {
let first_line = content.lines().find(|l| !l.trim().is_empty())?;
let title = first_line.strip_prefix("# ")?.trim();
if title.is_empty() {
None
} else {
Some(title.to_string())
}
}
pub fn sync_h1_in_body(body: &str, title: &str) -> String {
let mut found = false;
let mut new_lines: Vec<String> = Vec::new();
let mut is_first_nonblank = true;
for line in body.lines() {
if is_first_nonblank && !line.trim().is_empty() {
is_first_nonblank = false;
if line.starts_with("# ") {
new_lines.push(format!("# {}", title));
found = true;
} else {
new_lines.push(format!("# {}", title));
new_lines.push(String::new());
new_lines.push(line.to_string());
found = true;
}
} else {
new_lines.push(line.to_string());
}
}
if !found {
if body.is_empty() {
return format!("# {}\n\n", title);
} else {
let mut result = format!("# {}\n\n", title);
result.push_str(body);
return result;
}
}
let mut result = new_lines.join("\n");
if body.ends_with('\n') && !result.ends_with('\n') {
result.push('\n');
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_prettify_filename() {
assert_eq!(prettify_filename("my-note"), "My Note");
assert_eq!(prettify_filename("some_file"), "Some File");
assert_eq!(prettify_filename("already-cool"), "Already Cool");
}
#[test]
fn test_slugify_title() {
assert_eq!(slugify_title("My Cool Entry"), "my-cool-entry.md");
assert_eq!(slugify_title("Hello World!"), "hello-world.md");
assert_eq!(slugify_title(" spaces "), "spaces.md");
assert_eq!(slugify_title(""), "untitled.md");
}
#[test]
fn test_preserve_style() {
assert_eq!(
apply_filename_style("My Entry: A Story", &FilenameStyle::Preserve),
"My Entry A Story"
);
assert_eq!(
apply_filename_style("Hello World!", &FilenameStyle::Preserve),
"Hello World!"
);
assert_eq!(
apply_filename_style("café notes", &FilenameStyle::Preserve),
"café notes"
);
assert_eq!(
apply_filename_style("file/with\\bad:chars", &FilenameStyle::Preserve),
"filewithbadchars"
);
assert_eq!(
apply_filename_style("", &FilenameStyle::Preserve),
"Untitled"
);
assert_eq!(
apply_filename_style("***", &FilenameStyle::Preserve),
"Untitled"
);
}
#[test]
fn test_kebab_case_style() {
assert_eq!(
apply_filename_style("My Cool Entry", &FilenameStyle::KebabCase),
"my-cool-entry"
);
assert_eq!(
apply_filename_style("Hello World!", &FilenameStyle::KebabCase),
"hello-world"
);
}
#[test]
fn test_snake_case_style() {
assert_eq!(
apply_filename_style("My Cool Entry", &FilenameStyle::SnakeCase),
"my_cool_entry"
);
assert_eq!(
apply_filename_style("Hello World!", &FilenameStyle::SnakeCase),
"hello_world"
);
assert_eq!(
apply_filename_style("", &FilenameStyle::SnakeCase),
"untitled"
);
}
#[test]
fn test_screaming_snake_case_style() {
assert_eq!(
apply_filename_style("My Cool Entry", &FilenameStyle::ScreamingSnakeCase),
"MY_COOL_ENTRY"
);
assert_eq!(
apply_filename_style("Hello World!", &FilenameStyle::ScreamingSnakeCase),
"HELLO_WORLD"
);
assert_eq!(
apply_filename_style("", &FilenameStyle::ScreamingSnakeCase),
"UNTITLED"
);
}
#[test]
fn test_has_non_portable_chars() {
assert_eq!(has_non_portable_chars("my-note.md"), None);
assert_eq!(has_non_portable_chars("Hello World.md"), None);
assert_eq!(has_non_portable_chars("café notes.md"), None);
assert_eq!(has_non_portable_chars("README.md"), None);
assert!(has_non_portable_chars("what?.md").is_some());
assert!(has_non_portable_chars("he said \"hello\".md").is_some());
assert!(has_non_portable_chars("file:name.md").is_some());
assert!(has_non_portable_chars("a*b.md").is_some());
assert!(has_non_portable_chars("a|b.md").is_some());
assert!(has_non_portable_chars("a<b>.md").is_some());
assert!(has_non_portable_chars(".hidden.md").is_some());
assert!(has_non_portable_chars("~temp.md").is_some());
assert!(has_non_portable_chars(" leading-space.md").is_some());
assert!(has_non_portable_chars("trailing-space .md").is_some());
assert!(has_non_portable_chars("file\x00name.md").is_some());
assert!(has_non_portable_chars("file\x1Fname.md").is_some());
assert!(has_non_portable_chars("file\x7Fname.md").is_some());
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("what?.md"), "what.md");
assert_eq!(
sanitize_filename("he said \"hello\".md"),
"he said hello.md"
);
assert_eq!(sanitize_filename("a:b:c.md"), "abc.md");
assert_eq!(sanitize_filename(".hidden.md"), "hidden.md");
assert_eq!(sanitize_filename("~temp.md"), "temp.md");
assert_eq!(sanitize_filename(" leading.md"), "leading.md");
assert_eq!(sanitize_filename("trailing .md"), "trailing.md");
assert_eq!(sanitize_filename("...dots...md"), "dots.md");
assert_eq!(sanitize_filename("???.md"), "Untitled.md");
assert_eq!(sanitize_filename("*"), "Untitled");
assert_eq!(sanitize_filename("good-name.md"), "good-name.md");
assert_eq!(sanitize_filename("no-ext"), "no-ext");
}
#[test]
fn test_slugify_title_with_style() {
assert_eq!(
slugify_title_with_style("My Entry", &FilenameStyle::Preserve),
"My Entry.md"
);
assert_eq!(
slugify_title_with_style("My Entry", &FilenameStyle::KebabCase),
"my-entry.md"
);
assert_eq!(
slugify_title_with_style("My Entry", &FilenameStyle::SnakeCase),
"my_entry.md"
);
assert_eq!(
slugify_title_with_style("My Entry", &FilenameStyle::ScreamingSnakeCase),
"MY_ENTRY.md"
);
}
#[test]
fn test_extract_first_line_h1() {
assert_eq!(
extract_first_line_h1("# My Title\n\nBody text"),
Some("My Title".to_string())
);
assert_eq!(
extract_first_line_h1("\n\n# My Title\n\nBody text"),
Some("My Title".to_string())
);
assert_eq!(extract_first_line_h1("No heading here"), None);
assert_eq!(extract_first_line_h1("## Not H1"), None);
assert_eq!(extract_first_line_h1("# "), None);
assert_eq!(extract_first_line_h1("# \n\nBody"), None);
assert_eq!(extract_first_line_h1(""), None);
assert_eq!(extract_first_line_h1("Body\n# Later Heading"), None);
}
#[test]
fn test_sync_h1_in_body() {
assert_eq!(
sync_h1_in_body("# Old Title\n\nBody text", "New Title"),
"# New Title\n\nBody text"
);
assert_eq!(
sync_h1_in_body("Body text\nMore text", "New Title"),
"# New Title\n\nBody text\nMore text"
);
assert_eq!(sync_h1_in_body("", "New Title"), "# New Title\n\n");
assert_eq!(
sync_h1_in_body("# Old Title\n\nBody\n", "New Title"),
"# New Title\n\nBody\n"
);
assert_eq!(
sync_h1_in_body("\n\n# Old Title\n\nBody", "New Title"),
"\n\n# New Title\n\nBody"
);
}
}