use std::cmp::Ordering;
use std::collections::HashSet;
use slug::slugify;
use crate::html::{as_plain_text, Content};
#[derive(Debug, Default)]
struct SlugFactory {
known: HashSet<String>,
}
impl SlugFactory {
fn slug(&mut self, text: &str) -> Result<String, SlugError> {
let simple = slugify(text);
let slug = self.unique_slug(&simple)?;
self.known.insert(slug.clone());
Ok(slug)
}
fn given(&mut self, slug: &str) {
self.known.insert(slug.into());
}
fn unique_slug(&mut self, simple: &str) -> Result<String, SlugError> {
const MAX_TRIES: usize = 1000;
if self.known.contains(simple) {
for i in 2..MAX_TRIES {
let slug = format!("{simple}{i}");
if !self.known.contains(&slug) {
return Ok(slug);
}
}
Err(SlugError::Unique(MAX_TRIES, simple.into()))
} else {
Ok(simple.into())
}
}
}
#[derive(Debug, thiserror::Error, Eq, PartialEq)]
pub enum SlugError {
#[error("failed to generate a unique slug in {0} tries for {1:?}")]
Unique(usize, String),
}
#[cfg(test)]
mod test_slugs {
use super::SlugFactory;
#[test]
fn short_and_simple() {
let mut slugs = SlugFactory::default();
assert_eq!(slugs.slug("Foo"), Ok("foo".into()));
}
#[test]
fn unique_for_identical_simple_headings() {
let mut slugs = SlugFactory::default();
assert_ne!(slugs.slug("Foo"), slugs.slug("Foo"));
}
}
#[derive(Debug, Default)]
struct HeadingNumberer {
prev: Vec<usize>,
}
impl HeadingNumberer {
fn number(&mut self, level: usize) -> String {
match level.cmp(&self.prev.len()) {
Ordering::Equal => {
if let Some(n) = self.prev.pop() {
self.prev.push(n + 1);
} else {
self.prev.push(1);
}
}
Ordering::Greater => {
self.prev.push(1);
}
Ordering::Less => {
assert!(!self.prev.is_empty());
self.prev.pop();
if let Some(n) = self.prev.pop() {
self.prev.push(n + 1);
} else {
self.prev.push(1);
}
}
}
let mut s = String::new();
for i in self.prev.iter() {
if !s.is_empty() {
s.push('.');
}
s.push_str(&i.to_string());
}
s
}
}
#[cfg(test)]
mod test_numberer {
use super::HeadingNumberer;
#[test]
fn numbering() {
let mut n = HeadingNumberer::default();
assert_eq!(n.number(1), "1");
assert_eq!(n.number(2), "1.1");
assert_eq!(n.number(1), "2");
assert_eq!(n.number(2), "2.1");
}
}
#[derive(Debug, Default)]
pub struct TableOfContents {
slugs: SlugFactory,
numbers: HeadingNumberer,
headings: Vec<Heading>,
}
impl TableOfContents {
pub fn push_heading(
&mut self,
level: usize,
content: &Content,
slug: Option<&str>,
) -> Result<Heading, ToCError> {
let text = as_plain_text(&[content.clone()]);
let slug = if let Some(slug) = slug {
self.slugs.given(slug);
slug.to_string()
} else {
self.slugs.slug(&text)?
};
let number = self.numbers.number(level);
let heading = Heading {
level,
slug: slug.clone(),
number,
content: content.clone(),
};
self.headings.push(heading.clone());
Ok(heading)
}
pub fn iter(&self) -> impl Iterator<Item = &Heading> {
self.headings.iter()
}
}
#[derive(Debug, thiserror::Error)]
pub enum ToCError {
#[error(transparent)]
Slug(#[from] SlugError),
}
#[derive(Debug, Clone)]
pub struct Heading {
pub level: usize,
pub slug: String,
pub number: String,
pub content: Content,
}
impl PartialEq for Heading {
fn eq(&self, other: &Self) -> bool {
self.level == other.level && self.slug == other.slug && self.number == other.number
}
}
impl Eq for &Heading {}
#[cfg(test)]
mod test_toc {
use crate::html::Content;
use super::{Heading, TableOfContents};
#[test]
fn iterate() {
let mut toc = TableOfContents::default();
toc.push_heading(1, &Content::Text("Foo".into()), None)
.unwrap();
toc.push_heading(1, &Content::Text("Bar".into()), None)
.unwrap();
let expected_foo = Heading {
level: 1,
slug: "foo".into(),
number: "1".into(),
content: Content::Text("Foo".into()),
};
let expected_bar = Heading {
level: 1,
slug: "bar".into(),
number: "2".into(),
content: Content::Text("Bar".into()),
};
let actual: Vec<&Heading> = toc.iter().collect();
assert_eq!(actual, vec![&expected_foo, &expected_bar]);
}
#[test]
fn uses_given_slug() {
let mut toc = TableOfContents::default();
toc.push_heading(1, &Content::Text("Foo".into()), Some("foo"))
.unwrap();
toc.push_heading(1, &Content::Text("Foo".into()), None)
.unwrap();
let expected_1 = Heading {
level: 1,
slug: "foo".into(),
number: "1".into(),
content: Content::Text("Foo".into()),
};
let expected_2 = Heading {
level: 1,
slug: "foo2".into(),
number: "2".into(),
content: Content::Text("Foo".into()),
};
let actual: Vec<&Heading> = toc.iter().collect();
assert_eq!(actual, vec![&expected_1, &expected_2]);
}
}