use std::io::{self, Write};
use anyhow::bail;
use crate::entity;
pub(crate) fn resolve_title(title: Option<String>) -> anyhow::Result<String> {
if let Some(t) = title {
let t = t.trim().to_string();
if t.is_empty() {
bail!("Title must not be empty");
}
return Ok(t);
}
let mut stdout = io::stdout();
write!(stdout, "Title: ")?;
stdout.flush()?;
let mut line = String::new();
io::stdin().read_line(&mut line)?;
let entered = line.trim().to_string();
if entered.is_empty() {
bail!("Title must not be empty");
}
Ok(entered)
}
const SLUG_MAX: usize = 100;
fn slug_is_well_formed(s: &str) -> bool {
!s.starts_with('-')
&& !s.ends_with('-')
&& !s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
pub(crate) fn resolve_slug(title: &str, slug: Option<String>) -> anyhow::Result<String> {
if let Some(s) = slug {
if s.is_empty() {
bail!("--slug must not be empty");
}
if s.len() > SLUG_MAX {
bail!("--slug too long ({} bytes; max {SLUG_MAX})", s.len());
}
if !slug_is_well_formed(&s) {
bail!("--slug must be lowercase a-z, 0-9 and '-' (no leading/trailing '-'): {s:?}");
}
return Ok(s);
}
let derived = entity::derive_slug(title);
if derived.is_empty() {
bail!("Could not derive a slug from the title; pass --slug");
}
Ok(truncate_slug(&derived, SLUG_MAX))
}
fn truncate_slug(slug: &str, max: usize) -> String {
if slug.len() <= max {
return slug.to_string();
}
let prefix = slug.get(..max).unwrap_or(slug);
match prefix.rfind('-') {
Some(cut) if cut > 0 => prefix.get(..cut).unwrap_or(prefix).to_string(),
_ => prefix.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_title_uses_and_trims_the_argument() {
assert_eq!(
resolve_title(Some(" My Title ".into())).unwrap(),
"My Title"
);
}
#[test]
fn resolve_title_rejects_an_empty_argument() {
let err = resolve_title(Some(" ".into())).unwrap_err();
assert!(err.to_string().contains("Title must not be empty"));
}
#[test]
fn resolve_slug_prefers_the_explicit_flag() {
assert_eq!(
resolve_slug("My Title", Some("custom".into())).unwrap(),
"custom"
);
}
#[test]
fn resolve_slug_derives_from_the_title_when_unset() {
assert_eq!(resolve_slug("My Title", None).unwrap(), "my-title");
}
#[test]
fn resolve_slug_bails_when_a_symbol_only_title_derives_to_nothing() {
let err = resolve_slug("!!!", None).unwrap_err();
assert!(err.to_string().contains("pass --slug"));
}
#[test]
fn resolve_slug_rejects_an_empty_explicit_flag() {
let err = resolve_slug("My Title", Some(String::new())).unwrap_err();
assert!(err.to_string().contains("must not be empty"));
}
#[test]
fn resolve_slug_accepts_a_well_formed_explicit_flag_verbatim() {
assert_eq!(
resolve_slug("My Title", Some("a-clean-slug".into())).unwrap(),
"a-clean-slug"
);
assert_eq!(resolve_slug("My Title", Some("a".into())).unwrap(), "a");
}
#[test]
fn resolve_slug_rejects_a_path_hostile_explicit_flag() {
for hostile in [
"../../etc", "a/b", "..", ".hidden", "has space", "UPPER", "under_score", "-leading", "trailing-", "tab\tslug", ] {
let err = resolve_slug("My Title", Some(hostile.into())).unwrap_err();
assert!(
err.to_string().contains("lowercase"),
"{hostile:?} must be rejected with the charset message: {err}"
);
}
}
#[test]
fn resolve_slug_rejects_an_overlong_explicit_flag() {
let long = "a".repeat(SLUG_MAX + 1);
let err = resolve_slug("My Title", Some(long)).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("too long"), "{msg}");
assert!(msg.contains(&SLUG_MAX.to_string()), "{msg}");
}
#[test]
fn resolve_slug_truncates_a_derived_slug_over_the_cap() {
let title = "word ".repeat(40); let slug = resolve_slug(&title, None).unwrap();
assert!(slug.len() <= SLUG_MAX, "len {}", slug.len());
assert!(!slug.is_empty());
}
#[test]
fn truncate_slug_returns_a_within_cap_slug_unchanged() {
assert_eq!(truncate_slug("short-slug", SLUG_MAX), "short-slug");
assert_eq!(truncate_slug("abc", 3), "abc");
}
#[test]
fn truncate_slug_cuts_at_the_last_dash_within_the_prefix() {
assert_eq!(truncate_slug("alpha-beta-gamma", 10), "alpha");
}
#[test]
fn truncate_slug_hard_cuts_on_a_boundary_when_no_usable_dash() {
assert_eq!(truncate_slug("supercalifragilistic", 5), "super");
assert_eq!(truncate_slug("-leadingdash", 4), "-lea");
}
#[test]
fn truncate_slug_never_empties_a_non_empty_slug() {
assert!(!truncate_slug("aaaaaaaaaa", 3).is_empty());
assert!(!truncate_slug("a-b-c-d-e-f", 4).is_empty());
}
}