use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Context;
use rayon::prelude::*;
use scraps_libs::git::GitCommand;
use scraps_libs::model::{context::Ctx, scrap::Scrap};
use tracing::{Level, span};
use crate::error::{BuildError, ScrapsError, ScrapsResult};
use crate::input::file::read_scraps::{self, ScrapsWithReadme};
use super::hash::content_hash;
use super::link_table::link_edges;
use super::schema::{IrFile, ScrapObject};
use super::store::IrStore;
const README_PATH: &str = "README.md";
struct Source {
rel_path: String,
stem: String,
ctx: Option<Ctx>,
text: String,
hash: String,
}
struct Loaded {
scrap: Scrap,
rel_path: String,
}
struct LoadedWiki {
store: IrStore,
entries: Vec<Loaded>,
objects: Vec<ScrapObject>,
git_head: Option<String>,
dirty: bool,
}
impl LoadedWiki {
fn save(&self) {
let _span = span!(Level::INFO, "save_ir").entered();
let scraps: Vec<Scrap> = self.entries.iter().map(|l| l.scrap.clone()).collect();
let mut file = IrFile::new(self.objects.clone(), link_edges(&scraps));
file.git_head = self.git_head.clone();
if let Err(e) = self.store.save(&file) {
tracing::warn!(
"could not write the IR to {}: {e}",
self.store.file_path().display()
);
}
}
}
fn read_source(scraps_dir: &Path, path: &Path) -> ScrapsResult<Source> {
let rel_path = read_scraps::relative_path(scraps_dir, path)?;
let (stem, ctx) = read_scraps::scrap_identity(scraps_dir, path)?;
let text = std::fs::read_to_string(path).context(ScrapsError::ReadScrap(path.to_path_buf()))?;
let hash = content_hash(text.as_bytes());
Ok(Source {
rel_path,
stem,
ctx,
text,
hash,
})
}
fn reject_same_key(scraps_dir: &Path, sources: &[Source]) -> ScrapsResult<()> {
let mut seen: HashMap<(&Option<Ctx>, &str), &str> = HashMap::with_capacity(sources.len());
for src in sources {
if let Some(first) = seen.insert((&src.ctx, &src.stem), &src.rel_path) {
let path = |rel_path: &str| scraps_dir.join(rel_path);
return Err(ScrapsError::SameKey(path(first), path(&src.rel_path)).into());
}
}
Ok(())
}
fn load(scraps_dir: &Path, exclude_dirs: &[PathBuf]) -> ScrapsResult<LoadedWiki> {
let _span = span!(Level::INFO, "load_scraps").entered();
let span_read_sources = span!(Level::INFO, "read_sources").entered();
let paths = read_scraps::to_scrap_paths(scraps_dir, exclude_dirs)?;
let mut sources = paths
.into_par_iter()
.map(|path| read_source(scraps_dir, &path))
.collect::<ScrapsResult<Vec<Source>>>()?;
sources.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
reject_same_key(scraps_dir, &sources)?;
span_read_sources.exit();
let span_read_ir = span!(Level::INFO, "read_ir").entered();
let store = IrStore::new(scraps_dir);
let stored = store.load();
let had_file = stored.is_some();
let git_head = stored.as_ref().and_then(|file| file.git_head.clone());
let known: HashMap<String, ScrapObject> = stored
.map(|file| {
file.scraps
.into_iter()
.map(|o| (o.path.clone(), o))
.collect()
})
.unwrap_or_default();
span_read_ir.exit();
let span_rebuild_scraps = span!(Level::INFO, "rebuild_scraps").entered();
let entries: Vec<(Loaded, ScrapObject, bool)> = sources
.into_par_iter()
.map(|src| {
let stored = known.get(&src.rel_path);
let (scrap, object, reused) = match stored {
Some(object) if object.hash == src.hash => {
let facts = object.to_facts();
let scrap = Scrap::from_facts(&src.stem, &src.ctx, &src.text, facts);
(scrap, object.clone(), true)
}
_ => {
let scrap = Scrap::new(&src.stem, &src.ctx, &src.text);
let mut object = ScrapObject::from_scrap(&src.rel_path, &src.hash, &scrap);
object.commited_ts = stored.and_then(|o| o.commited_ts);
(scrap, object, false)
}
};
let loaded = Loaded {
scrap,
rel_path: src.rel_path,
};
(loaded, object, reused)
})
.collect();
span_rebuild_scraps.exit();
let reused = entries.iter().filter(|(_, _, reused)| *reused).count();
let dirty = !had_file || reused != entries.len() || known.len() != entries.len();
let (entries, objects): (Vec<Loaded>, Vec<ScrapObject>) =
entries.into_iter().map(|(l, o, _)| (l, o)).unzip();
Ok(LoadedWiki {
store,
entries,
objects,
git_head,
dirty,
})
}
pub fn load_scraps(scraps_dir: &Path, exclude_dirs: &[PathBuf]) -> ScrapsResult<Vec<Scrap>> {
let wiki = load(scraps_dir, exclude_dirs)?;
if wiki.dirty {
wiki.save();
}
Ok(wiki.entries.into_iter().map(|l| l.scrap).collect())
}
pub fn load_scraps_with_timestamps<GC: GitCommand + Copy>(
scraps_dir: &Path,
exclude_dirs: &[PathBuf],
git_command: Option<GC>,
) -> ScrapsResult<ScrapsWithReadme> {
let mut wiki = load(scraps_dir, exclude_dirs)?;
let timestamps = match git_command {
Some(gc) => stamp(&mut wiki, gc, scraps_dir)?,
None => vec![None; wiki.entries.len()],
};
if wiki.dirty {
wiki.save();
}
let mut readme_text = None;
let mut scraps_with_ts = Vec::with_capacity(wiki.entries.len());
for (loaded, ts) in wiki.entries.into_iter().zip(timestamps) {
if loaded.rel_path == README_PATH {
readme_text = Some(loaded.scrap.md_text().to_string());
} else {
scraps_with_ts.push((loaded.scrap, ts));
}
}
Ok((scraps_with_ts, readme_text))
}
fn stamp<GC: GitCommand + Copy>(
wiki: &mut LoadedWiki,
git_command: GC,
scraps_dir: &Path,
) -> ScrapsResult<Vec<Option<i64>>> {
let head = head_commit(git_command, scraps_dir)?;
let same_head = head.is_some() && head == wiki.git_head;
let pending: Vec<usize> = wiki
.entries
.iter()
.zip(&wiki.objects)
.enumerate()
.filter(|(_, (loaded, object))| {
loaded.rel_path != README_PATH && !(same_head && object.commited_ts.is_some())
})
.map(|(i, _)| i)
.collect();
let rel_paths: Vec<&str> = pending
.iter()
.map(|&i| wiki.entries[i].rel_path.as_str())
.collect();
let looked_up = commited_ts_many(git_command, scraps_dir, &rel_paths)?;
for (i, ts) in pending.into_iter().zip(looked_up) {
let object = &mut wiki.objects[i];
if object.commited_ts != ts {
object.commited_ts = ts;
wiki.dirty = true;
}
}
if wiki.git_head != head {
wiki.git_head = head;
wiki.dirty = true;
}
Ok(wiki.objects.iter().map(|o| o.commited_ts).collect())
}
fn head_commit<GC: GitCommand>(git_command: GC, scraps_dir: &Path) -> ScrapsResult<Option<String>> {
match git_command.head_commit(scraps_dir) {
Ok(head) => Ok(head),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::Error::new(e).context(BuildError::GitCommitedTs)),
}
}
fn commited_ts_many<GC: GitCommand>(
git_command: GC,
scraps_dir: &Path,
rel_paths: &[&str],
) -> ScrapsResult<Vec<Option<i64>>> {
if rel_paths.is_empty() {
return Ok(Vec::new());
}
match git_command.commited_ts_many(scraps_dir, rel_paths) {
Ok(timestamps) => Ok(timestamps),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::warn!(
"git binary not found; skipping commited_ts under {}",
scraps_dir.display()
);
Ok(vec![None; rel_paths.len()])
}
Err(e) => Err(anyhow::Error::new(e).context(BuildError::GitCommitedTs)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::schema::FORMAT_VERSION;
use crate::ir::store::{IR_DIR_NAME, IR_FILE_NAME};
use crate::test_fixtures::TempScrapProject;
use scraps_libs::git::GitCommandImpl;
use std::fs;
fn exclude(project: &TempScrapProject) -> Vec<PathBuf> {
vec![project.static_dir.clone(), project.output_dir.clone()]
}
fn ir_path(project: &TempScrapProject) -> PathBuf {
project.project_root.join(IR_DIR_NAME).join(IR_FILE_NAME)
}
fn read_ir(project: &TempScrapProject) -> IrFile {
serde_json::from_slice(&fs::read(ir_path(project)).unwrap()).unwrap()
}
fn write_ir(project: &TempScrapProject, file: &IrFile) {
fs::write(ir_path(project), serde_json::to_vec(file).unwrap()).unwrap();
}
#[test]
fn it_writes_the_ir_on_first_load() {
let project = TempScrapProject::new();
project
.add_scrap("b.md", b"# B\n\n[[a]] and [[missing]]")
.add_scrap("a.md", b"# A\n");
let scraps = load_scraps(&project.project_root, &exclude(&project)).unwrap();
let stems: Vec<String> = scraps.iter().map(|s| s.stem().to_string()).collect();
assert_eq!(stems, vec!["a", "b"]);
let ir = read_ir(&project);
assert_eq!(ir.format_version, FORMAT_VERSION);
let paths: Vec<&str> = ir.scraps.iter().map(|o| o.path.as_str()).collect();
assert_eq!(paths, vec!["a.md", "b.md"]);
assert_eq!(ir.links.len(), 2);
assert!(ir.links.iter().any(|e| e.to.stem == "a" && e.resolved));
assert!(
ir.links
.iter()
.any(|e| e.to.stem == "missing" && !e.resolved)
);
let dir = project.project_root.join(IR_DIR_NAME);
assert_eq!(fs::read_to_string(dir.join(".gitignore")).unwrap(), "*\n");
}
#[test]
fn it_matches_a_fresh_parse_cold_and_warm() {
let project = TempScrapProject::new();
project
.add_scrap(
"a.md",
b"# A\n\n[[Ctx/b#Sec]] #[[t]] ![[b]]\n\n- [ ] task\n\n```sh\nls\n```\n",
)
.add_scrap_with_context("Ctx", "b.md", b"---\nk: v\n---\n\n## Sec\n");
let excl = exclude(&project);
let mut expected = read_scraps::to_all_scraps(&project.project_root, &excl).unwrap();
expected.sort_by_key(Scrap::self_key);
let mut cold = load_scraps(&project.project_root, &excl).unwrap();
cold.sort_by_key(Scrap::self_key);
let mut warm = load_scraps(&project.project_root, &excl).unwrap();
warm.sort_by_key(Scrap::self_key);
assert_eq!(cold, expected);
assert_eq!(warm, expected);
}
#[test]
fn it_reuses_stored_facts_for_an_unchanged_source() {
let project = TempScrapProject::new();
project.add_scrap("a.md", b"# Real\n");
let excl = exclude(&project);
load_scraps(&project.project_root, &excl).unwrap();
let mut ir = read_ir(&project);
ir.scraps[0].headings[0].text = "Ghost".to_string();
write_ir(&project, &ir);
let scraps = load_scraps(&project.project_root, &excl).unwrap();
assert_eq!(scraps[0].headings()[0].text, "Ghost");
assert_eq!(read_ir(&project).scraps[0].headings[0].text, "Ghost");
}
#[test]
fn it_reparses_a_changed_source() {
let project = TempScrapProject::new();
project.add_scrap("a.md", b"# Before\n");
let excl = exclude(&project);
load_scraps(&project.project_root, &excl).unwrap();
let before = read_ir(&project).scraps[0].hash.clone();
project.add_scrap("a.md", b"# After\n");
let scraps = load_scraps(&project.project_root, &excl).unwrap();
assert_eq!(scraps[0].headings()[0].text, "After");
let ir = read_ir(&project);
assert_ne!(ir.scraps[0].hash, before);
assert_eq!(ir.scraps[0].headings[0].text, "After");
}
#[test]
fn it_drops_a_removed_source() {
let project = TempScrapProject::new();
project
.add_scrap("a.md", b"# A\n")
.add_scrap("b.md", b"[[a]]\n");
let excl = exclude(&project);
load_scraps(&project.project_root, &excl).unwrap();
fs::remove_file(project.project_root.join("b.md")).unwrap();
let scraps = load_scraps(&project.project_root, &excl).unwrap();
assert_eq!(scraps.len(), 1);
let ir = read_ir(&project);
assert_eq!(ir.scraps.len(), 1);
assert!(ir.links.is_empty());
}
#[test]
fn it_rebuilds_from_a_corrupt_file() {
let project = TempScrapProject::new();
project.add_scrap("a.md", b"# A\n");
let excl = exclude(&project);
load_scraps(&project.project_root, &excl).unwrap();
fs::write(ir_path(&project), b"not json").unwrap();
let scraps = load_scraps(&project.project_root, &excl).unwrap();
assert_eq!(scraps[0].headings()[0].text, "A");
assert_eq!(read_ir(&project).scraps.len(), 1);
}
#[test]
fn it_discards_a_file_from_another_version() {
let project = TempScrapProject::new();
project.add_scrap("a.md", b"# Real\n");
let excl = exclude(&project);
load_scraps(&project.project_root, &excl).unwrap();
let mut ir = read_ir(&project);
ir.format_version = FORMAT_VERSION + 1;
ir.scraps[0].headings[0].text = "Ghost".to_string();
write_ir(&project, &ir);
let scraps = load_scraps(&project.project_root, &excl).unwrap();
assert_eq!(scraps[0].headings()[0].text, "Real");
assert_eq!(read_ir(&project).format_version, FORMAT_VERSION);
}
#[test]
fn it_partitions_the_root_readme_only_for_the_build() {
let project = TempScrapProject::new();
project
.add_scrap("README.md", b"# Readme body")
.add_scrap("a.md", b"# A\n");
let excl = exclude(&project);
let all = load_scraps(&project.project_root, &excl).unwrap();
assert_eq!(all.len(), 2);
let (scraps, readme) =
load_scraps_with_timestamps::<GitCommandImpl>(&project.project_root, &excl, None)
.unwrap();
assert_eq!(scraps.len(), 1);
assert_eq!(scraps[0].0.stem().to_string(), "a");
assert_eq!(scraps[0].1, None);
assert_eq!(readme.as_deref(), Some("# Readme body"));
}
#[test]
fn it_skips_the_ir_directory_itself() {
let project = TempScrapProject::new();
project.add_scrap("a.md", b"# A\n");
let excl = exclude(&project);
load_scraps(&project.project_root, &excl).unwrap();
fs::write(
project.project_root.join(IR_DIR_NAME).join("note.md"),
b"# Not a scrap",
)
.unwrap();
let scraps = load_scraps(&project.project_root, &excl).unwrap();
assert_eq!(scraps.len(), 1);
}
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Clone, Copy)]
struct CountingGit {
head: Option<&'static str>,
calls: &'static AtomicUsize,
}
impl CountingGit {
fn new(head: Option<&'static str>) -> CountingGit {
CountingGit {
head,
calls: Box::leak(Box::new(AtomicUsize::new(0))),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
impl GitCommand for CountingGit {
fn init(&self, _path: &Path) -> std::io::Result<()> {
Ok(())
}
fn commited_ts(&self, _path: &Path) -> std::io::Result<Option<i64>> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(Some(1_700_000_000))
}
fn is_git_repository(&self, _path: &Path) -> std::io::Result<bool> {
Ok(true)
}
fn head_commit(&self, _path: &Path) -> std::io::Result<Option<String>> {
Ok(self.head.map(String::from))
}
}
fn two_scrap_project() -> TempScrapProject {
let project = TempScrapProject::new();
project
.add_scrap("a.md", b"# A\n")
.add_scrap("b.md", b"[[a]]\n")
.add_scrap("README.md", b"# Readme\n");
project
}
#[test]
fn it_records_timestamps_under_the_head_they_were_read_at() {
let project = two_scrap_project();
let excl = exclude(&project);
let git = CountingGit::new(Some("h1"));
let (scraps, _) =
load_scraps_with_timestamps(&project.project_root, &excl, Some(git)).unwrap();
assert_eq!(git.calls(), 2, "README is not stamped");
assert!(scraps.iter().all(|(_, ts)| *ts == Some(1_700_000_000)));
let ir = read_ir(&project);
assert_eq!(ir.git_head.as_deref(), Some("h1"));
let stamped = ir.scraps.iter().filter(|o| o.commited_ts.is_some()).count();
assert_eq!(stamped, 2);
}
#[test]
fn it_reuses_timestamps_while_head_is_unchanged() {
let project = two_scrap_project();
let excl = exclude(&project);
let git = CountingGit::new(Some("h1"));
load_scraps_with_timestamps(&project.project_root, &excl, Some(git)).unwrap();
load_scraps(&project.project_root, &excl).unwrap();
project.add_scrap("a.md", b"# A edited\n");
let (scraps, _) =
load_scraps_with_timestamps(&project.project_root, &excl, Some(git)).unwrap();
assert_eq!(git.calls(), 2);
assert!(scraps.iter().all(|(_, ts)| *ts == Some(1_700_000_000)));
assert_eq!(read_ir(&project).git_head.as_deref(), Some("h1"));
}
#[test]
fn it_asks_git_again_when_head_moves() {
let project = two_scrap_project();
let excl = exclude(&project);
let first = CountingGit::new(Some("h1"));
load_scraps_with_timestamps(&project.project_root, &excl, Some(first)).unwrap();
let second = CountingGit::new(Some("h2"));
load_scraps_with_timestamps(&project.project_root, &excl, Some(second)).unwrap();
assert_eq!(second.calls(), 2);
assert_eq!(read_ir(&project).git_head.as_deref(), Some("h2"));
}
#[test]
fn it_never_reuses_timestamps_without_a_head() {
let project = two_scrap_project();
let excl = exclude(&project);
let git = CountingGit::new(None);
load_scraps_with_timestamps(&project.project_root, &excl, Some(git)).unwrap();
load_scraps_with_timestamps(&project.project_root, &excl, Some(git)).unwrap();
assert_eq!(git.calls(), 4);
assert_eq!(read_ir(&project).git_head, None);
}
use std::sync::Mutex;
#[derive(Clone, Copy)]
struct BatchGit {
asked: &'static Mutex<Vec<Vec<String>>>,
}
impl BatchGit {
fn new() -> BatchGit {
BatchGit {
asked: Box::leak(Box::new(Mutex::new(Vec::new()))),
}
}
fn asked(&self) -> Vec<Vec<String>> {
self.asked.lock().unwrap().clone()
}
}
impl GitCommand for BatchGit {
fn init(&self, _path: &Path) -> std::io::Result<()> {
Ok(())
}
fn commited_ts(&self, _path: &Path) -> std::io::Result<Option<i64>> {
Ok(None)
}
fn commited_ts_many(
&self,
_dir: &Path,
rel_paths: &[&str],
) -> std::io::Result<Vec<Option<i64>>> {
let batch = rel_paths.iter().map(|p| p.to_string()).collect();
self.asked.lock().unwrap().push(batch);
Ok(rel_paths
.iter()
.map(|rel_path| match *rel_path {
"a.md" => Some(1),
"ctx/b.md" => Some(2),
_ => None,
})
.collect())
}
fn is_git_repository(&self, _path: &Path) -> std::io::Result<bool> {
Ok(true)
}
fn head_commit(&self, _path: &Path) -> std::io::Result<Option<String>> {
Ok(Some("h1".to_string()))
}
}
#[test]
fn it_asks_git_once_for_every_scrap_to_stamp() {
let project = TempScrapProject::new();
project
.add_scrap("a.md", b"# A\n")
.add_scrap_with_context("ctx", "b.md", b"[[a]]\n")
.add_scrap("README.md", b"# Readme\n");
let excl = exclude(&project);
let git = BatchGit::new();
let (scraps, _) =
load_scraps_with_timestamps(&project.project_root, &excl, Some(git)).unwrap();
load_scraps_with_timestamps(&project.project_root, &excl, Some(git)).unwrap();
assert_eq!(git.asked(), vec![vec!["a.md", "ctx/b.md"]]);
let stamped: Vec<(String, Option<i64>)> = scraps
.iter()
.map(|(scrap, ts)| (scrap.stem().to_string(), *ts))
.collect();
assert_eq!(
stamped,
vec![("a".to_string(), Some(1)), ("b".to_string(), Some(2))]
);
}
use crate::test_fixtures::SimpleTempDir;
use std::process::Command;
const BASE_TS: i64 = 1_700_000_000;
fn git_at(repo: &Path, ts: i64, args: &[&str]) {
let mut command = Command::new("git");
for (key, _) in std::env::vars_os() {
if key.to_string_lossy().starts_with("GIT_") {
command.env_remove(key);
}
}
let date = format!("@{ts} +0000");
let output = command
.current_dir(repo)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_DATE", &date)
.env("GIT_COMMITTER_DATE", &date)
.args([
"-c",
"user.name=scraps",
"-c",
"user.email=scraps@example.com",
])
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn it_reads_last_commit_times_from_a_real_repository() {
if std::env::var_os("GIT_DIR").is_some() {
eprintln!("skipped: GIT_DIR is set");
return;
}
let repo = SimpleTempDir::new();
let root = repo.path.as_path();
repo.add_file("wiki/a.md", b"# A\n")
.add_file("wiki/b.md", b"1\n2\n3\n4\n5\n");
git_at(root, BASE_TS, &["init", "-q"]);
git_at(root, BASE_TS, &["add", "."]);
git_at(root, BASE_TS, &["commit", "-q", "-m", "init"]);
git_at(root, BASE_TS, &["checkout", "-q", "-b", "side"]);
repo.add_file("wiki/b.md", b"1\n2\n3\n4\nside\n");
git_at(root, BASE_TS + 20, &["commit", "-q", "-am", "side"]);
git_at(root, BASE_TS + 20, &["checkout", "-q", "-"]);
repo.add_file("wiki/b.md", b"main\n2\n3\n4\n5\n");
git_at(root, BASE_TS + 10, &["commit", "-q", "-am", "main"]);
git_at(
root,
BASE_TS + 30,
&["merge", "-q", "--no-ff", "-m", "merge", "side"],
);
repo.add_file("wiki/ctx/c.md", b"# C\n");
git_at(root, BASE_TS + 40, &["add", "."]);
git_at(root, BASE_TS + 40, &["commit", "-q", "-m", "c"]);
let (scraps, _) =
load_scraps_with_timestamps(&root.join("wiki"), &[], Some(GitCommandImpl::new()))
.unwrap();
let stamped: Vec<(String, Option<i64>)> = scraps
.iter()
.map(|(scrap, ts)| (scrap.stem().to_string(), *ts))
.collect();
assert_eq!(
stamped,
vec![
("a".to_string(), Some(BASE_TS)),
("b".to_string(), Some(BASE_TS + 30)),
("c".to_string(), Some(BASE_TS + 40)),
]
);
}
const GUIDE_NFD: &str = "\u{30ab}\u{3099}\u{30a4}\u{30c8}\u{3099}";
const GUIDE_NFC: &str = "\u{30ac}\u{30a4}\u{30c9}";
#[test]
fn it_keys_a_decomposed_file_name_in_nfc_and_stores_the_path_as_read() {
let project = TempScrapProject::new();
project
.add_scrap(&format!("{GUIDE_NFD}.md"), b"# Guide\n")
.add_scrap("a.md", format!("[[{GUIDE_NFC}]]").as_bytes());
let scraps = load_scraps(&project.project_root, &exclude(&project)).unwrap();
assert!(scraps.iter().any(|s| s.stem().to_string() == GUIDE_NFC));
let ir = read_ir(&project);
let guide = ir.scraps.iter().find(|o| o.stem == GUIDE_NFC).unwrap();
assert_eq!(guide.path, format!("{GUIDE_NFD}.md"));
assert!(ir.links.iter().all(|e| e.resolved));
}
fn source(rel_path: &str) -> Source {
let root = Path::new("/wiki");
let (stem, ctx) = read_scraps::scrap_identity(root, &root.join(rel_path)).unwrap();
Source {
rel_path: rel_path.to_string(),
stem,
ctx,
text: String::new(),
hash: String::new(),
}
}
#[test]
fn it_rejects_two_sources_whose_names_normalize_to_one_key() {
let nfd = format!("guide/{GUIDE_NFD}.md");
let nfc = format!("guide/{GUIDE_NFC}.md");
let sources = vec![source("a.md"), source(&nfc), source(&nfd)];
let err = reject_same_key(Path::new("/wiki"), &sources).unwrap_err();
assert_eq!(
err.downcast_ref::<ScrapsError>(),
Some(&ScrapsError::SameKey(
Path::new("/wiki").join(&nfc),
Path::new("/wiki").join(&nfd)
))
);
let message = err.to_string();
assert!(
message.contains(&nfc) && message.contains(&nfd),
"{message}"
);
}
#[test]
fn it_fails_to_load_a_wiki_holding_a_name_in_both_normalization_forms() {
let project = TempScrapProject::new();
project
.add_scrap(&format!("{GUIDE_NFC}.md"), b"# Composed\n")
.add_scrap(&format!("{GUIDE_NFD}.md"), b"# Decomposed\n");
let md_files = read_scraps::to_scrap_paths(&project.project_root, &exclude(&project));
if md_files.unwrap().len() < 2 {
return;
}
let err = load_scraps(&project.project_root, &exclude(&project)).unwrap_err();
assert_eq!(
err.downcast_ref::<ScrapsError>(),
Some(&ScrapsError::SameKey(
project.project_root.join(format!("{GUIDE_NFD}.md")),
project.project_root.join(format!("{GUIDE_NFC}.md"))
))
);
}
#[test]
fn it_accepts_the_same_stem_under_different_contexts() {
let sources = vec![source("a/t.md"), source("b/t.md"), source("t.md")];
assert!(reject_same_key(Path::new("/wiki"), &sources).is_ok());
}
}