use std::{
fs::{self},
path::{Path, PathBuf},
};
use anyhow::Context;
use scraps_libs::model::{context::Ctx, scrap::Scrap};
use crate::error::{ScrapsError, ScrapsResult};
pub(crate) fn to_scrap_paths(
scraps_dir_path: &Path,
exclude_dirs: &[PathBuf],
) -> ScrapsResult<Vec<PathBuf>> {
let read_dir = fs::read_dir(scraps_dir_path).context(ScrapsError::ReadScraps)?;
let paths = read_dir
.map(|entry_res| {
let entry = entry_res?;
let entry_path = entry.path();
if entry.file_name().to_string_lossy().starts_with('.') {
return Ok(vec![]);
}
match entry.file_type() {
Ok(file_type) if file_type.is_file() => match entry_path.extension() {
Some(ext) if ext == "md" => Ok(vec![entry_path]),
_ => Ok(vec![]),
},
Ok(file_type) if file_type.is_dir() => {
if exclude_dirs.iter().any(|p| p == &entry_path) {
Ok(vec![])
} else {
to_scrap_paths(&entry_path, &[])
}
}
res => res
.map(|_| vec![])
.context(ScrapsError::ReadScrap(entry_path)),
}
})
.collect::<ScrapsResult<Vec<Vec<PathBuf>>>>()?;
Ok(paths.into_iter().flatten().collect::<Vec<PathBuf>>())
}
pub(crate) fn to_scrap_by_path(
scraps_dir_path: &Path,
scrap_file_path: &Path,
) -> ScrapsResult<Scrap> {
let file_prefix = scrap_file_path
.file_stem()
.ok_or(ScrapsError::ReadScrap(scrap_file_path.to_path_buf()))
.map(|o| o.to_str())
.and_then(|fp| fp.ok_or(ScrapsError::ReadScrap(scrap_file_path.to_path_buf())))?;
let changed_directory_path = scrap_file_path
.strip_prefix(scraps_dir_path)
.context(ScrapsError::ReadScrap(scrap_file_path.to_path_buf()))?;
let ctx_segments: Vec<String> = changed_directory_path
.parent()
.map(|p| {
p.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
let ctx: Option<Ctx> = if ctx_segments.is_empty() {
None
} else {
Some(Ctx::from(ctx_segments.join("/").as_str()))
};
let md_text = fs::read_to_string(scrap_file_path)
.context(ScrapsError::ReadScrap(scrap_file_path.to_path_buf()))?;
let scrap = Scrap::new(file_prefix, &ctx, &md_text);
Ok(scrap)
}
pub(crate) fn to_all_scraps(
scraps_dir_path: &Path,
exclude_dirs: &[PathBuf],
) -> ScrapsResult<Vec<Scrap>> {
let paths = to_scrap_paths(scraps_dir_path, exclude_dirs)?;
paths
.iter()
.map(|path| to_scrap_by_path(scraps_dir_path, path))
.collect()
}
pub(crate) fn to_all_scraps_with_timestamps<
GC: scraps_libs::git::GitCommand + Send + Sync + Copy,
>(
scraps_dir_path: &Path,
exclude_dirs: &[PathBuf],
git_command: Option<GC>,
) -> ScrapsResult<(Vec<(Scrap, Option<i64>)>, Option<String>)> {
use rayon::prelude::*;
let paths = to_scrap_paths(scraps_dir_path, exclude_dirs)?;
let readme_path = scraps_dir_path.join("README.md");
let (readme_paths, scrap_paths): (Vec<_>, Vec<_>) =
paths.into_iter().partition(|path| path == &readme_path);
let readme_text = readme_paths
.first()
.map(|path| fs::read_to_string(path).context(crate::error::BuildError::ReadREADMEFile))
.transpose()?;
let scraps_with_ts = scrap_paths
.into_par_iter()
.map(|path| {
let scrap = to_scrap_by_path(scraps_dir_path, &path)?;
let commited_ts = match git_command {
Some(gc) => match gc.commited_ts(&path) {
Ok(ts) => ts,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::warn!(
"git binary not found; skipping commited_ts for {}",
path.display()
);
None
}
Err(e) => {
return Err(
anyhow::Error::new(e).context(crate::error::BuildError::GitCommitedTs)
);
}
},
None => None,
};
Ok((scrap, commited_ts))
})
.collect::<ScrapsResult<Vec<(Scrap, Option<i64>)>>>()?;
Ok((scraps_with_ts, readme_text))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::TempScrapProject;
use std::collections::HashSet;
fn collect_titles(scraps: &[Scrap]) -> HashSet<String> {
scraps
.iter()
.map(|s| {
let key = s.self_key();
let title = key.title().to_string();
match key.ctx() {
Some(ctx) => format!("{ctx}/{title}"),
None => title,
}
})
.collect()
}
#[test]
fn skips_static_output_and_dotfiles() {
let project = TempScrapProject::new();
project.add_scrap("intro.md", b"# Intro");
project.add_static_file("style.md", b"# In static");
std::fs::write(project.output_dir.join("page.md"), b"# In output").unwrap();
let dot_dir = project.project_root.join(".cache");
std::fs::create_dir_all(&dot_dir).unwrap();
std::fs::write(dot_dir.join("note.md"), b"# In dotdir").unwrap();
std::fs::write(project.project_root.join(".hidden.md"), b"# Hidden").unwrap();
let exclude = vec![project.static_dir.clone(), project.output_dir.clone()];
let scraps = to_all_scraps(&project.project_root, &exclude).unwrap();
let titles = collect_titles(&scraps);
assert_eq!(titles, HashSet::from(["intro".to_string()]));
}
#[test]
fn collects_nested_ctx_under_project_root() {
let project = TempScrapProject::new();
project.add_scrap("root.md", b"# Root");
project.add_scrap_with_context("architecture", "overview.md", b"# Overview");
let exclude = vec![project.static_dir.clone(), project.output_dir.clone()];
let scraps = to_all_scraps(&project.project_root, &exclude).unwrap();
let titles = collect_titles(&scraps);
assert_eq!(
titles,
HashSet::from(["root".to_string(), "architecture/overview".to_string()])
);
}
#[test]
fn readme_at_project_root_is_partitioned() {
let project = TempScrapProject::new();
project
.add_scrap("README.md", b"# Readme body")
.add_scrap("intro.md", b"# Intro");
let exclude = vec![project.static_dir.clone(), project.output_dir.clone()];
let (scraps_with_ts, readme) = to_all_scraps_with_timestamps::<
scraps_libs::git::GitCommandImpl,
>(&project.project_root, &exclude, None)
.unwrap();
let titles = collect_titles(
&scraps_with_ts
.iter()
.map(|(s, _)| s.clone())
.collect::<Vec<_>>(),
);
assert_eq!(titles, HashSet::from(["intro".to_string()]));
assert_eq!(readme.as_deref(), Some("# Readme body"));
}
}