scraps 1.2.0

Scraps is a portable CLI knowledge hub for managing interconnected Markdown documentation with Wiki-link notation.
use std::io::{BufWriter, Write};
use std::{fs::File, path::PathBuf};

use crate::error::BuildError;
use crate::error::{anyhow::Context, ScrapsResult};
use scraps_libs::model::base_url::BaseUrl;
use scraps_libs::model::scrap::Scrap;

use super::search_index_tera;
use super::serde::search_index_scraps::SearchIndexScrapsTera;

pub struct SearchIndexRender {
    static_dir_path: PathBuf,
    output_dir_path: PathBuf,
}

impl SearchIndexRender {
    pub fn new(
        static_dir_path: &PathBuf,
        output_dir_path: &PathBuf,
    ) -> ScrapsResult<SearchIndexRender> {
        std::fs::create_dir_all(output_dir_path).context(BuildError::CreateDir)?;

        Ok(SearchIndexRender {
            static_dir_path: static_dir_path.to_owned(),
            output_dir_path: output_dir_path.to_owned(),
        })
    }

    pub fn run(&self, base_url: &BaseUrl, scraps: &[Scrap]) -> ScrapsResult<()> {
        let serialize_scraps = SearchIndexScrapsTera::new(scraps);

        Self::render_search_index_json(self, base_url, &serialize_scraps)
    }

    fn render_search_index_json(
        &self,
        base_url: &BaseUrl,
        scraps: &SearchIndexScrapsTera,
    ) -> ScrapsResult<()> {
        let (tera, mut context) = search_index_tera::base(
            base_url,
            self.static_dir_path.join("*.json").to_str().unwrap(),
        )?;
        let template_name = if tera.get_template_names().any(|t| t == "search_index.json") {
            "search_index.json"
        } else {
            "__builtins/search_index.json"
        };
        context.insert("scraps", scraps);
        let file_path = &self.output_dir_path.join("search_index.json");
        // tera renders in many small writes, so buffer them into one file write.
        let mut wtr = BufWriter::new(
            File::create(file_path).context(BuildError::WriteFailure(file_path.clone()))?,
        );
        tera.render_to(template_name, &context, &mut wtr)
            .context(BuildError::WriteFailure(file_path.clone()))?;
        wtr.flush()
            .context(BuildError::WriteFailure(file_path.clone()))
    }
}

#[cfg(test)]
mod tests {
    use crate::test_fixtures::{temp_scrap_project, TempScrapProject};
    use rstest::rstest;
    use std::fs;
    use url::Url;

    use super::*;
    use scraps_libs::model::scrap::Scrap;

    #[rstest]
    fn it_run(#[from(temp_scrap_project)] project: TempScrapProject) {
        // Add static search_index.json template
        project.add_static_file(
            "search_index.json",
            b"[{% for scrap in scraps %}{ \"title\": \"{{ scrap.link_title }}\", \"url\": \"{{ base_url}}scraps/{{ scrap.file_stem }}.html\" }{% if not loop.last %},{% endif %}{% endfor %}]"
        );

        let base_url = BaseUrl::new(Url::parse("http://localhost:1112/").unwrap()).unwrap();

        // Create scraps
        let sc1 = Scrap::new("scrap1", &None, "# header1");
        let sc2 = Scrap::new("scrap2", &Some("Context".into()), "## header2");
        let scraps = vec![sc1, sc2];

        let render = SearchIndexRender::new(&project.static_dir, &project.output_dir).unwrap();
        render.run(&base_url, &scraps).unwrap();

        let result = fs::read_to_string(project.output_path("search_index.json")).unwrap();
        assert_eq!(
            result,
            "[{ \"title\": \"scrap1\", \"url\": \"http://localhost:1112/scraps/scrap1.html\" },{ \"title\": \"Context/scrap2\", \"url\": \"http://localhost:1112/scraps/context/scrap2.html\" }]");
    }

    #[rstest]
    fn it_run_builtin_emits_valid_json(#[from(temp_scrap_project)] project: TempScrapProject) {
        // No static template, so the builtin one is rendered.
        let base_url = BaseUrl::new(Url::parse("http://localhost:1112/").unwrap()).unwrap();
        let scraps = vec![
            Scrap::new("scrap1", &None, "# header1"),
            Scrap::new("scrap2", &Some("Context".into()), "## header2"),
            Scrap::new("quote\"in title", &None, "# header3"),
        ];

        let render = SearchIndexRender::new(&project.static_dir, &project.output_dir).unwrap();
        render.run(&base_url, &scraps).unwrap();

        let result = fs::read_to_string(project.output_path("search_index.json")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();

        let entries = parsed.as_array().unwrap();
        assert_eq!(entries.len(), 3);
        assert_eq!(entries[0]["title"], "scrap1");
        assert_eq!(
            entries[0]["url"],
            "http://localhost:1112/scraps/scrap1.html"
        );
        assert_eq!(entries[1]["title"], "Context/scrap2");
        assert_eq!(
            entries[1]["url"],
            "http://localhost:1112/scraps/context/scrap2.html"
        );
        assert_eq!(entries[2]["title"], "quote\"in title");
    }
}