8542f7f29d
* Transition to 2018 edition * Update Travis CI badge in README * Remove non-idiomatic `extern crate` lines
569 lines
17 KiB
Rust
569 lines
17 KiB
Rust
use std::collections::VecDeque;
|
|
use std::fmt::{self, Display, Formatter};
|
|
use std::fs::{self, File};
|
|
use std::io::{Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use super::summary::{parse_summary, Link, SectionNumber, Summary, SummaryItem};
|
|
use crate::config::BuildConfig;
|
|
use crate::errors::*;
|
|
|
|
/// Load a book into memory from its `src/` directory.
|
|
pub fn load_book<P: AsRef<Path>>(src_dir: P, cfg: &BuildConfig) -> Result<Book> {
|
|
let src_dir = src_dir.as_ref();
|
|
let summary_md = src_dir.join("SUMMARY.md");
|
|
|
|
let mut summary_content = String::new();
|
|
File::open(summary_md)
|
|
.chain_err(|| "Couldn't open SUMMARY.md")?
|
|
.read_to_string(&mut summary_content)?;
|
|
|
|
let summary = parse_summary(&summary_content).chain_err(|| "Summary parsing failed")?;
|
|
|
|
if cfg.create_missing {
|
|
create_missing(&src_dir, &summary).chain_err(|| "Unable to create missing chapters")?;
|
|
}
|
|
|
|
load_book_from_disk(&summary, src_dir)
|
|
}
|
|
|
|
fn create_missing(src_dir: &Path, summary: &Summary) -> Result<()> {
|
|
let mut items: Vec<_> = summary
|
|
.prefix_chapters
|
|
.iter()
|
|
.chain(summary.numbered_chapters.iter())
|
|
.chain(summary.suffix_chapters.iter())
|
|
.collect();
|
|
|
|
while !items.is_empty() {
|
|
let next = items.pop().expect("already checked");
|
|
|
|
if let SummaryItem::Link(ref link) = *next {
|
|
let filename = src_dir.join(&link.location);
|
|
if !filename.exists() {
|
|
if let Some(parent) = filename.parent() {
|
|
if !parent.exists() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
}
|
|
debug!("Creating missing file {}", filename.display());
|
|
|
|
let mut f = File::create(&filename)?;
|
|
writeln!(f, "# {}", link.name)?;
|
|
}
|
|
|
|
items.extend(&link.nested_items);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A dumb tree structure representing a book.
|
|
///
|
|
/// For the moment a book is just a collection of `BookItems` which are
|
|
/// accessible by either iterating (immutably) over the book with [`iter()`], or
|
|
/// recursively applying a closure to each section to mutate the chapters, using
|
|
/// [`for_each_mut()`].
|
|
///
|
|
/// [`iter()`]: #method.iter
|
|
/// [`for_each_mut()`]: #method.for_each_mut
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct Book {
|
|
/// The sections in this book.
|
|
pub sections: Vec<BookItem>,
|
|
__non_exhaustive: (),
|
|
}
|
|
|
|
impl Book {
|
|
/// Create an empty book.
|
|
pub fn new() -> Self {
|
|
Default::default()
|
|
}
|
|
|
|
/// Get a depth-first iterator over the items in the book.
|
|
pub fn iter(&self) -> BookItems<'_> {
|
|
BookItems {
|
|
items: self.sections.iter().collect(),
|
|
}
|
|
}
|
|
|
|
/// Recursively apply a closure to each item in the book, allowing you to
|
|
/// mutate them.
|
|
///
|
|
/// # Note
|
|
///
|
|
/// Unlike the `iter()` method, this requires a closure instead of returning
|
|
/// an iterator. This is because using iterators can possibly allow you
|
|
/// to have iterator invalidation errors.
|
|
pub fn for_each_mut<F>(&mut self, mut func: F)
|
|
where
|
|
F: FnMut(&mut BookItem),
|
|
{
|
|
for_each_mut(&mut func, &mut self.sections);
|
|
}
|
|
|
|
/// Append a `BookItem` to the `Book`.
|
|
pub fn push_item<I: Into<BookItem>>(&mut self, item: I) -> &mut Self {
|
|
self.sections.push(item.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
pub fn for_each_mut<'a, F, I>(func: &mut F, items: I)
|
|
where
|
|
F: FnMut(&mut BookItem),
|
|
I: IntoIterator<Item = &'a mut BookItem>,
|
|
{
|
|
for item in items {
|
|
if let BookItem::Chapter(ch) = item {
|
|
for_each_mut(func, &mut ch.sub_items);
|
|
}
|
|
|
|
func(item);
|
|
}
|
|
}
|
|
|
|
/// Enum representing any type of item which can be added to a book.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub enum BookItem {
|
|
/// A nested chapter.
|
|
Chapter(Chapter),
|
|
/// A section separator.
|
|
Separator,
|
|
}
|
|
|
|
impl From<Chapter> for BookItem {
|
|
fn from(other: Chapter) -> BookItem {
|
|
BookItem::Chapter(other)
|
|
}
|
|
}
|
|
|
|
/// The representation of a "chapter", usually mapping to a single file on
|
|
/// disk however it may contain multiple sub-chapters.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct Chapter {
|
|
/// The chapter's name.
|
|
pub name: String,
|
|
/// The chapter's contents.
|
|
pub content: String,
|
|
/// The chapter's section number, if it has one.
|
|
pub number: Option<SectionNumber>,
|
|
/// Nested items.
|
|
pub sub_items: Vec<BookItem>,
|
|
/// The chapter's location, relative to the `SUMMARY.md` file.
|
|
pub path: PathBuf,
|
|
/// An ordered list of the names of each chapter above this one, in the hierarchy.
|
|
pub parent_names: Vec<String>,
|
|
}
|
|
|
|
impl Chapter {
|
|
/// Create a new chapter with the provided content.
|
|
pub fn new<P: Into<PathBuf>>(
|
|
name: &str,
|
|
content: String,
|
|
path: P,
|
|
parent_names: Vec<String>,
|
|
) -> Chapter {
|
|
Chapter {
|
|
name: name.to_string(),
|
|
content,
|
|
path: path.into(),
|
|
parent_names,
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Use the provided `Summary` to load a `Book` from disk.
|
|
///
|
|
/// You need to pass in the book's source directory because all the links in
|
|
/// `SUMMARY.md` give the chapter locations relative to it.
|
|
pub(crate) fn load_book_from_disk<P: AsRef<Path>>(summary: &Summary, src_dir: P) -> Result<Book> {
|
|
debug!("Loading the book from disk");
|
|
let src_dir = src_dir.as_ref();
|
|
|
|
let prefix = summary.prefix_chapters.iter();
|
|
let numbered = summary.numbered_chapters.iter();
|
|
let suffix = summary.suffix_chapters.iter();
|
|
|
|
let summary_items = prefix.chain(numbered).chain(suffix);
|
|
|
|
let mut chapters = Vec::new();
|
|
|
|
for summary_item in summary_items {
|
|
let chapter = load_summary_item(summary_item, src_dir, Vec::new())?;
|
|
chapters.push(chapter);
|
|
}
|
|
|
|
Ok(Book {
|
|
sections: chapters,
|
|
__non_exhaustive: (),
|
|
})
|
|
}
|
|
|
|
fn load_summary_item<P: AsRef<Path>>(
|
|
item: &SummaryItem,
|
|
src_dir: P,
|
|
parent_names: Vec<String>,
|
|
) -> Result<BookItem> {
|
|
match *item {
|
|
SummaryItem::Separator => Ok(BookItem::Separator),
|
|
SummaryItem::Link(ref link) => {
|
|
load_chapter(link, src_dir, parent_names).map(BookItem::Chapter)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn load_chapter<P: AsRef<Path>>(
|
|
link: &Link,
|
|
src_dir: P,
|
|
parent_names: Vec<String>,
|
|
) -> Result<Chapter> {
|
|
debug!("Loading {} ({})", link.name, link.location.display());
|
|
let src_dir = src_dir.as_ref();
|
|
|
|
let location = if link.location.is_absolute() {
|
|
link.location.clone()
|
|
} else {
|
|
src_dir.join(&link.location)
|
|
};
|
|
|
|
let mut f = File::open(&location)
|
|
.chain_err(|| format!("Chapter file not found, {}", link.location.display()))?;
|
|
|
|
let mut content = String::new();
|
|
f.read_to_string(&mut content)
|
|
.chain_err(|| format!("Unable to read \"{}\" ({})", link.name, location.display()))?;
|
|
|
|
let stripped = location
|
|
.strip_prefix(&src_dir)
|
|
.expect("Chapters are always inside a book");
|
|
|
|
let mut sub_item_parents = parent_names.clone();
|
|
let mut ch = Chapter::new(&link.name, content, stripped, parent_names);
|
|
ch.number = link.number.clone();
|
|
|
|
sub_item_parents.push(link.name.clone());
|
|
let sub_items = link
|
|
.nested_items
|
|
.iter()
|
|
.map(|i| load_summary_item(i, src_dir, sub_item_parents.clone()))
|
|
.collect::<Result<Vec<_>>>()?;
|
|
|
|
ch.sub_items = sub_items;
|
|
|
|
Ok(ch)
|
|
}
|
|
|
|
/// A depth-first iterator over the items in a book.
|
|
///
|
|
/// # Note
|
|
///
|
|
/// This struct shouldn't be created directly, instead prefer the
|
|
/// [`Book::iter()`] method.
|
|
///
|
|
/// [`Book::iter()`]: struct.Book.html#method.iter
|
|
pub struct BookItems<'a> {
|
|
items: VecDeque<&'a BookItem>,
|
|
}
|
|
|
|
impl<'a> Iterator for BookItems<'a> {
|
|
type Item = &'a BookItem;
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
let item = self.items.pop_front();
|
|
|
|
if let Some(&BookItem::Chapter(ref ch)) = item {
|
|
// if we wanted a breadth-first iterator we'd `extend()` here
|
|
for sub_item in ch.sub_items.iter().rev() {
|
|
self.items.push_front(sub_item);
|
|
}
|
|
}
|
|
|
|
item
|
|
}
|
|
}
|
|
|
|
impl Display for Chapter {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
|
if let Some(ref section_number) = self.number {
|
|
write!(f, "{} ", section_number)?;
|
|
}
|
|
|
|
write!(f, "{}", self.name)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::Write;
|
|
use tempfile::{Builder as TempFileBuilder, TempDir};
|
|
|
|
const DUMMY_SRC: &str = "
|
|
# Dummy Chapter
|
|
|
|
this is some dummy text.
|
|
|
|
And here is some \
|
|
more text.
|
|
";
|
|
|
|
/// Create a dummy `Link` in a temporary directory.
|
|
fn dummy_link() -> (Link, TempDir) {
|
|
let temp = TempFileBuilder::new().prefix("book").tempdir().unwrap();
|
|
|
|
let chapter_path = temp.path().join("chapter_1.md");
|
|
File::create(&chapter_path)
|
|
.unwrap()
|
|
.write_all(DUMMY_SRC.as_bytes())
|
|
.unwrap();
|
|
|
|
let link = Link::new("Chapter 1", chapter_path);
|
|
|
|
(link, temp)
|
|
}
|
|
|
|
/// Create a nested `Link` written to a temporary directory.
|
|
fn nested_links() -> (Link, TempDir) {
|
|
let (mut root, temp_dir) = dummy_link();
|
|
|
|
let second_path = temp_dir.path().join("second.md");
|
|
|
|
File::create(&second_path)
|
|
.unwrap()
|
|
.write_all(b"Hello World!")
|
|
.unwrap();
|
|
|
|
let mut second = Link::new("Nested Chapter 1", &second_path);
|
|
second.number = Some(SectionNumber(vec![1, 2]));
|
|
|
|
root.nested_items.push(second.clone().into());
|
|
root.nested_items.push(SummaryItem::Separator);
|
|
root.nested_items.push(second.clone().into());
|
|
|
|
(root, temp_dir)
|
|
}
|
|
|
|
#[test]
|
|
fn load_a_single_chapter_from_disk() {
|
|
let (link, temp_dir) = dummy_link();
|
|
let should_be = Chapter::new(
|
|
"Chapter 1",
|
|
DUMMY_SRC.to_string(),
|
|
"chapter_1.md",
|
|
Vec::new(),
|
|
);
|
|
|
|
let got = load_chapter(&link, temp_dir.path(), Vec::new()).unwrap();
|
|
assert_eq!(got, should_be);
|
|
}
|
|
|
|
#[test]
|
|
fn cant_load_a_nonexistent_chapter() {
|
|
let link = Link::new("Chapter 1", "/foo/bar/baz.md");
|
|
|
|
let got = load_chapter(&link, "", Vec::new());
|
|
assert!(got.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn load_recursive_link_with_separators() {
|
|
let (root, temp) = nested_links();
|
|
|
|
let nested = Chapter {
|
|
name: String::from("Nested Chapter 1"),
|
|
content: String::from("Hello World!"),
|
|
number: Some(SectionNumber(vec![1, 2])),
|
|
path: PathBuf::from("second.md"),
|
|
parent_names: vec![String::from("Chapter 1")],
|
|
sub_items: Vec::new(),
|
|
};
|
|
let should_be = BookItem::Chapter(Chapter {
|
|
name: String::from("Chapter 1"),
|
|
content: String::from(DUMMY_SRC),
|
|
number: None,
|
|
path: PathBuf::from("chapter_1.md"),
|
|
parent_names: Vec::new(),
|
|
sub_items: vec![
|
|
BookItem::Chapter(nested.clone()),
|
|
BookItem::Separator,
|
|
BookItem::Chapter(nested.clone()),
|
|
],
|
|
});
|
|
|
|
let got = load_summary_item(&SummaryItem::Link(root), temp.path(), Vec::new()).unwrap();
|
|
assert_eq!(got, should_be);
|
|
}
|
|
|
|
#[test]
|
|
fn load_a_book_with_a_single_chapter() {
|
|
let (link, temp) = dummy_link();
|
|
let summary = Summary {
|
|
numbered_chapters: vec![SummaryItem::Link(link)],
|
|
..Default::default()
|
|
};
|
|
let should_be = Book {
|
|
sections: vec![BookItem::Chapter(Chapter {
|
|
name: String::from("Chapter 1"),
|
|
content: String::from(DUMMY_SRC),
|
|
path: PathBuf::from("chapter_1.md"),
|
|
..Default::default()
|
|
})],
|
|
..Default::default()
|
|
};
|
|
|
|
let got = load_book_from_disk(&summary, temp.path()).unwrap();
|
|
|
|
assert_eq!(got, should_be);
|
|
}
|
|
|
|
#[test]
|
|
fn book_iter_iterates_over_sequential_items() {
|
|
let book = Book {
|
|
sections: vec![
|
|
BookItem::Chapter(Chapter {
|
|
name: String::from("Chapter 1"),
|
|
content: String::from(DUMMY_SRC),
|
|
..Default::default()
|
|
}),
|
|
BookItem::Separator,
|
|
],
|
|
..Default::default()
|
|
};
|
|
|
|
let should_be: Vec<_> = book.sections.iter().collect();
|
|
|
|
let got: Vec<_> = book.iter().collect();
|
|
|
|
assert_eq!(got, should_be);
|
|
}
|
|
|
|
#[test]
|
|
fn iterate_over_nested_book_items() {
|
|
let book = Book {
|
|
sections: vec![
|
|
BookItem::Chapter(Chapter {
|
|
name: String::from("Chapter 1"),
|
|
content: String::from(DUMMY_SRC),
|
|
number: None,
|
|
path: PathBuf::from("Chapter_1/index.md"),
|
|
parent_names: Vec::new(),
|
|
sub_items: vec![
|
|
BookItem::Chapter(Chapter::new(
|
|
"Hello World",
|
|
String::new(),
|
|
"Chapter_1/hello.md",
|
|
Vec::new(),
|
|
)),
|
|
BookItem::Separator,
|
|
BookItem::Chapter(Chapter::new(
|
|
"Goodbye World",
|
|
String::new(),
|
|
"Chapter_1/goodbye.md",
|
|
Vec::new(),
|
|
)),
|
|
],
|
|
}),
|
|
BookItem::Separator,
|
|
],
|
|
..Default::default()
|
|
};
|
|
|
|
let got: Vec<_> = book.iter().collect();
|
|
|
|
assert_eq!(got.len(), 5);
|
|
|
|
// checking the chapter names are in the order should be sufficient here...
|
|
let chapter_names: Vec<String> = got
|
|
.into_iter()
|
|
.filter_map(|i| match *i {
|
|
BookItem::Chapter(ref ch) => Some(ch.name.clone()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let should_be: Vec<_> = vec![
|
|
String::from("Chapter 1"),
|
|
String::from("Hello World"),
|
|
String::from("Goodbye World"),
|
|
];
|
|
|
|
assert_eq!(chapter_names, should_be);
|
|
}
|
|
|
|
#[test]
|
|
fn for_each_mut_visits_all_items() {
|
|
let mut book = Book {
|
|
sections: vec![
|
|
BookItem::Chapter(Chapter {
|
|
name: String::from("Chapter 1"),
|
|
content: String::from(DUMMY_SRC),
|
|
number: None,
|
|
path: PathBuf::from("Chapter_1/index.md"),
|
|
parent_names: Vec::new(),
|
|
sub_items: vec![
|
|
BookItem::Chapter(Chapter::new(
|
|
"Hello World",
|
|
String::new(),
|
|
"Chapter_1/hello.md",
|
|
Vec::new(),
|
|
)),
|
|
BookItem::Separator,
|
|
BookItem::Chapter(Chapter::new(
|
|
"Goodbye World",
|
|
String::new(),
|
|
"Chapter_1/goodbye.md",
|
|
Vec::new(),
|
|
)),
|
|
],
|
|
}),
|
|
BookItem::Separator,
|
|
],
|
|
..Default::default()
|
|
};
|
|
|
|
let num_items = book.iter().count();
|
|
let mut visited = 0;
|
|
|
|
book.for_each_mut(|_| visited += 1);
|
|
|
|
assert_eq!(visited, num_items);
|
|
}
|
|
|
|
#[test]
|
|
fn cant_load_chapters_with_an_empty_path() {
|
|
let (_, temp) = dummy_link();
|
|
let summary = Summary {
|
|
numbered_chapters: vec![SummaryItem::Link(Link {
|
|
name: String::from("Empty"),
|
|
location: PathBuf::from(""),
|
|
..Default::default()
|
|
})],
|
|
..Default::default()
|
|
};
|
|
|
|
let got = load_book_from_disk(&summary, temp.path());
|
|
assert!(got.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn cant_load_chapters_when_the_link_is_a_directory() {
|
|
let (_, temp) = dummy_link();
|
|
let dir = temp.path().join("nested");
|
|
fs::create_dir(&dir).unwrap();
|
|
|
|
let summary = Summary {
|
|
numbered_chapters: vec![SummaryItem::Link(Link {
|
|
name: String::from("nested"),
|
|
location: dir,
|
|
..Default::default()
|
|
})],
|
|
..Default::default()
|
|
};
|
|
|
|
let got = load_book_from_disk(&summary, temp.path());
|
|
assert!(got.is_err());
|
|
}
|
|
}
|