393 lines
11 KiB
Rust
393 lines
11 KiB
Rust
|
use std::fmt::{self, Display, Formatter};
|
||
|
use std::path::{Path, PathBuf};
|
||
|
use std::collections::VecDeque;
|
||
|
use std::fs::File;
|
||
|
use std::io::{Read, Write};
|
||
|
|
||
|
use super::summary::{parse_summary, Summary, Link, SummaryItem, SectionNumber};
|
||
|
use errors::*;
|
||
|
|
||
|
|
||
|
/// Load a book into memory from its `src/` directory.
|
||
|
pub fn load_book<P: AsRef<Path>>(src_dir: P, create_if_not_present: bool) -> 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")?;
|
||
|
|
||
|
load_book_from_disk(&summary, src_dir, create_if_not_present)
|
||
|
}
|
||
|
|
||
|
|
||
|
/// A dumb tree structure representing a book.
|
||
|
///
|
||
|
/// For the moment a book is just a collection of `BookItems`.
|
||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||
|
pub struct Book {
|
||
|
/// The sections in this book.
|
||
|
pub sections: Vec<BookItem>,
|
||
|
}
|
||
|
|
||
|
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() }
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// 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,
|
||
|
}
|
||
|
|
||
|
/// 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,
|
||
|
}
|
||
|
|
||
|
impl Chapter {
|
||
|
/// Create a new chapter with the provided content.
|
||
|
pub fn new<P: Into<PathBuf>>(name: &str, content: String, path: P) -> Chapter {
|
||
|
Chapter {
|
||
|
name: name.to_string(),
|
||
|
content: content,
|
||
|
path: path.into(),
|
||
|
..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.
|
||
|
fn load_book_from_disk<P: AsRef<Path>>(summary: &Summary, src_dir: P, create_if_not_present: bool) -> 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, create_if_not_present)?;
|
||
|
chapters.push(chapter);
|
||
|
}
|
||
|
|
||
|
Ok(Book { sections: chapters })
|
||
|
}
|
||
|
|
||
|
fn load_summary_item<P: AsRef<Path>>(item: &SummaryItem, src_dir: P, create_if_not_present: bool) -> Result<BookItem> {
|
||
|
match *item {
|
||
|
SummaryItem::Separator => Ok(BookItem::Separator),
|
||
|
SummaryItem::Link(ref link) => {
|
||
|
let file = src_dir.as_ref().join(&link.location);
|
||
|
|
||
|
if create_if_not_present && !file.exists() {
|
||
|
let text = format!("# {}", link.name);
|
||
|
File::create(&file)?.write_all(text.as_bytes())?;
|
||
|
}
|
||
|
|
||
|
load_chapter(link, src_dir).map(|c| BookItem::Chapter(c))
|
||
|
},
|
||
|
}
|
||
|
}
|
||
|
|
||
|
fn load_chapter<P: AsRef<Path>>(link: &Link, src_dir: P) -> 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)?;
|
||
|
|
||
|
let stripped = location.strip_prefix(&src_dir).expect("Chapters are always inside a book");
|
||
|
|
||
|
let mut ch = Chapter::new(&link.name, content, stripped);
|
||
|
ch.number = link.number.clone();
|
||
|
|
||
|
let sub_items = link.nested_items
|
||
|
.iter()
|
||
|
.map(|i| load_summary_item(i, src_dir, false))
|
||
|
.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 tempdir::TempDir;
|
||
|
use std::io::Write;
|
||
|
use std::fs;
|
||
|
|
||
|
const DUMMY_SRC: &'static 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 = TempDir::new("book").unwrap();
|
||
|
|
||
|
let chapter_path = temp.path().join("chapter_1.md");
|
||
|
File::create(&chapter_path)
|
||
|
.unwrap()
|
||
|
.write(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("Hello World!".as_bytes())
|
||
|
.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");
|
||
|
|
||
|
let got = load_chapter(&link, temp_dir.path()).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, "");
|
||
|
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"),
|
||
|
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"),
|
||
|
sub_items: vec![
|
||
|
BookItem::Chapter(nested.clone()),
|
||
|
BookItem::Separator,
|
||
|
BookItem::Chapter(nested.clone()),
|
||
|
],
|
||
|
});
|
||
|
|
||
|
let got = load_summary_item(&SummaryItem::Link(root), temp.path(), false).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()
|
||
|
}),
|
||
|
],
|
||
|
};
|
||
|
|
||
|
let got = load_book_from_disk(&summary, temp.path(), false).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,
|
||
|
],
|
||
|
};
|
||
|
|
||
|
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"),
|
||
|
sub_items: vec![
|
||
|
BookItem::Chapter(Chapter::new("Hello World", String::new(), "Chapter_1/hello.md")),
|
||
|
BookItem::Separator,
|
||
|
BookItem::Chapter(Chapter::new("Goodbye World", String::new(), "Chapter_1/goodbye.md")),
|
||
|
],
|
||
|
}),
|
||
|
BookItem::Separator,
|
||
|
],
|
||
|
};
|
||
|
|
||
|
|
||
|
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 create_missing_book_items() {
|
||
|
let (link, temp) = dummy_link();
|
||
|
let summary = Summary {
|
||
|
numbered_chapters: vec![SummaryItem::Link(link)],
|
||
|
..Default::default()
|
||
|
};
|
||
|
|
||
|
let chapter_1 = temp.path().join("chapter_1.md");
|
||
|
fs::remove_file(&chapter_1).unwrap();
|
||
|
assert!(!chapter_1.exists());
|
||
|
|
||
|
load_book_from_disk(&summary, temp.path(), true).unwrap();
|
||
|
|
||
|
assert!(chapter_1.exists());
|
||
|
}
|
||
|
}
|