use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
use prov_graph::document::Document;
use prov_graph::error::Result;
use prov_graph::fs::ReadStorage;
use prov_graph::graph::{CensusEntry, LinkSite, Target};
use prov_graph::index::IdIndex;
use prov_graph::link::{self, Link};
use prov_graph::memo::lock;
use prov_store::fs::Storage;
use prov_store::index::IndexStore;
use super::Workspace;
use crate::change::{ChangeSet, FileOp};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Form {
Path,
Any,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct Forms {
by_path: bool,
by_id: bool,
}
impl Forms {
fn add(&mut self, link: &Link) {
if link.is_path_target() {
self.by_path = true;
} else if link.id_ref().is_some() {
self.by_id = true;
}
}
fn matches(self, form: Form) -> bool {
match form {
Form::Path => self.by_path,
Form::Any => self.by_path || self.by_id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Stamp {
modified: SystemTime,
len: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct DocEdges {
spanning: BTreeSet<PathBuf>,
targets: BTreeMap<PathBuf, Forms>,
}
#[derive(Debug, Clone)]
struct IndexedDoc {
stamp: Option<Stamp>,
edges: DocEdges,
}
#[derive(Debug, Clone)]
pub(crate) struct InboundIndex {
docs: BTreeMap<PathBuf, IndexedDoc>,
absent: BTreeSet<PathBuf>,
}
impl InboundIndex {
fn sources(&self, target: &Path, form: Form) -> BTreeSet<PathBuf> {
self.docs
.iter()
.filter(|(source, _)| source.as_path() != target)
.filter(|(_, doc)| {
doc.edges
.targets
.get(target)
.is_some_and(|f| f.matches(form))
})
.map(|(source, _)| source.clone())
.collect()
}
}
pub(crate) enum InboundPlan {
Nothing,
Update(Vec<(PathBuf, DocEdges)>),
Drop,
}
impl<FS: ReadStorage, Id, Ix: IdIndex> Workspace<FS, Id, Ix> {
fn edges_of(&self, path: &Path, doc: &Document) -> DocEdges {
let spanning = self.relations().spanning_relation();
let meta = fig::Value::from(&doc.meta);
let mut edges = DocEdges::default();
for edge in self.relations().edges(&meta) {
let link = Link::parse(&edge.target);
let Target::Path(target) = self.resolve_link(path, &link) else {
continue;
};
if Some(edge.relation.as_str()) == spanning {
edges.spanning.insert(target.clone());
}
edges.targets.entry(target).or_default().add(&link);
}
for body in link::scan_body_links(path, &doc.body) {
if body.image {
continue;
}
if let Target::Path(target) = self.resolve_link(path, &body.link) {
edges.targets.entry(target).or_default().add(&body.link);
}
}
edges
}
async fn stamp(&self, path: &Path) -> Result<Option<Stamp>> {
let meta = self.fs().metadata(&self.fs_path(path)).await?;
Ok(meta.modified().ok().map(|modified| Stamp {
modified,
len: meta.len(),
}))
}
async fn still_fresh(&self, index: &InboundIndex) -> Result<bool> {
for (path, doc) in &index.docs {
if doc.stamp.is_none() || self.stamp(path).await.ok().flatten() != doc.stamp {
return Ok(false);
}
}
for path in &index.absent {
if self.exists(path).await? {
return Ok(false);
}
}
Ok(true)
}
}
impl<FS: Storage, IdP, Ix: IndexStore> Workspace<FS, IdP, Ix> {
pub(crate) async fn inbound_sources(
&self,
target: &Path,
form: Form,
) -> Result<BTreeSet<PathBuf>> {
let target = link::normalize(target);
if let Some(sources) = self.inbound_from_index(&target, form).await? {
return Ok(sources);
}
let _scope = self.read_scope();
let (_spanning, inverse) = self.spanning_pair()?;
let root = self.spanning_root(&target, &inverse).await?;
let census = self.census(&root).await?;
let (index, stamped) = self.index_census(&root, &census).await?;
let sources = index.sources(&target, form);
*lock(&self.inbound) = stamped.then_some(index);
Ok(sources)
}
async fn inbound_from_index(
&self,
target: &Path,
form: Form,
) -> Result<Option<BTreeSet<PathBuf>>> {
let Some(index) = lock(&self.inbound).clone() else {
return Ok(None);
};
if !index.docs.contains_key(target) {
return Ok(None);
}
if !self.still_fresh(&index).await? {
*lock(&self.inbound) = None;
return Ok(None);
}
Ok(Some(index.sources(target, form)))
}
async fn index_census(
&self,
root: &Path,
census: &[CensusEntry],
) -> Result<(InboundIndex, bool)> {
let spanning = self.relations().spanning_relation();
let mut visited: BTreeSet<PathBuf> = BTreeSet::new();
visited.insert(link::normalize(root));
for entry in census {
if let LinkSite::Relation(relation) = &entry.site
&& Some(relation.as_str()) == spanning
&& let Some(target) = entry.resolution.resolved_path()
{
visited.insert(target.clone());
}
}
let mut stamped = true;
let mut docs = BTreeMap::new();
let mut absent = BTreeSet::new();
for path in visited {
let edges = match self.load(&path).await {
Ok((_, doc)) => self.edges_of(&path, &doc),
Err(_) => DocEdges::default(),
};
let stamp = self.stamp(&path).await.ok().flatten();
stamped &= stamp.is_some();
absent.extend(edges.spanning.iter().cloned());
docs.insert(path, IndexedDoc { stamp, edges });
}
absent.retain(|path| !docs.contains_key(path));
Ok((InboundIndex { docs, absent }, stamped))
}
pub(crate) fn plan_inbound(&self, cs: &ChangeSet) -> InboundPlan {
let guard = lock(&self.inbound);
let Some(index) = guard.as_ref() else {
return InboundPlan::Nothing;
};
let mut updates = Vec::new();
for op in cs.ops() {
match op {
FileOp::Write { path, bytes } => {
let path = link::normalize(path);
let Some(known) = index.docs.get(&path) else {
return InboundPlan::Drop;
};
let parsed = std::str::from_utf8(bytes)
.ok()
.and_then(|text| Document::parse(&path, text).ok());
let Some(doc) = parsed else {
return InboundPlan::Drop;
};
let edges = self.edges_of(&path, &doc);
if edges.spanning != known.edges.spanning {
return InboundPlan::Drop;
}
updates.push((path, edges));
}
FileOp::SetExecutable { .. } => {}
_ => return InboundPlan::Drop,
}
}
if updates.is_empty() {
InboundPlan::Nothing
} else {
InboundPlan::Update(updates)
}
}
pub(crate) async fn settle_inbound(&self, plan: InboundPlan) {
let updates = match plan {
InboundPlan::Nothing => return,
InboundPlan::Drop => {
self.forget_inbound();
return;
}
InboundPlan::Update(updates) => updates,
};
let mut stamps = Vec::with_capacity(updates.len());
for (path, edges) in updates {
match self.stamp(&path).await.ok().flatten() {
Some(stamp) => stamps.push((
path,
IndexedDoc {
stamp: Some(stamp),
edges,
},
)),
None => {
self.forget_inbound();
return;
}
}
}
if let Some(index) = lock(&self.inbound).as_mut() {
for (path, doc) in stamps {
index.docs.insert(path, doc);
}
}
}
pub(crate) fn forget_inbound(&self) {
*lock(&self.inbound) = None;
}
}
pub(crate) fn empty() -> Mutex<Option<InboundIndex>> {
Mutex::new(None)
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::*;
use crate::fs_faults::CountingFs;
use crate::identity::Minter;
use prov_graph::exec::block_on;
use prov_store::index::FileIndex;
use prov_testkit::{read, scratch, write};
fn tree(tag: &str) -> PathBuf {
let dir = scratch("inbound", tag);
write(
&dir,
"index.md",
"---\ntitle: Root\ncontents:\n- '[A](a.md)'\n- '[B](b.md)'\n- '[C](c.md)'\n---\n",
);
write(
&dir,
"a.md",
"---\ntitle: A\npart_of: '[Root](index.md)'\nlinks:\n- '[C](c.md)'\n---\n",
);
write(
&dir,
"b.md",
"---\ntitle: B\npart_of: '[Root](index.md)'\n---\n",
);
write(
&dir,
"c.md",
"---\ntitle: C\npart_of: '[Root](index.md)'\n---\n",
);
dir
}
fn ws(dir: &Path, fs: CountingFs) -> Workspace<CountingFs, Minter, FileIndex> {
Workspace::builder(fs)
.root(dir)
.identity(Minter::lazy(3))
.index(FileIndex::new(fig::Format::Yaml))
.build()
}
#[test]
fn a_second_retitle_reads_only_what_it_writes() {
let dir = tree("second-retitle");
let fs = CountingFs::default();
let mut w = ws(&dir, fs.clone());
assert_eq!(block_on(w.retitle(Path::new("c.md"), "C two")).unwrap(), 2);
let after_first: Vec<usize> = ["index.md", "a.md", "b.md", "c.md"]
.iter()
.map(|p| fs.doc_reads(&dir, p))
.collect();
assert_eq!(after_first, vec![1, 1, 1, 1], "the first ask is a census");
assert_eq!(
block_on(w.retitle(Path::new("c.md"), "C three")).unwrap(),
2
);
assert_eq!(fs.doc_reads(&dir, "c.md"), 2, "the document retitled");
assert_eq!(fs.doc_reads(&dir, "index.md"), 2, "a relabeled source");
assert_eq!(fs.doc_reads(&dir, "a.md"), 2, "a relabeled source");
assert_eq!(
fs.doc_reads(&dir, "b.md"),
1,
"a document that links nowhere near c.md was read again"
);
assert!(read(&dir, "index.md").contains("[C three](c.md)"));
assert!(read(&dir, "a.md").contains("[C three](c.md)"));
}
#[test]
fn rename_shares_the_index() {
let dir = tree("rename-shares");
let fs = CountingFs::default();
let mut w = ws(&dir, fs.clone());
assert_eq!(block_on(w.retitle(Path::new("c.md"), "C two")).unwrap(), 2);
block_on(w.rename(Path::new("c.md"), Path::new("d.md"))).unwrap();
assert_eq!(
fs.doc_reads(&dir, "b.md"),
1,
"not an inbound source; not re-read"
);
assert!(read(&dir, "index.md").contains("[C two](/d.md)"));
assert!(read(&dir, "a.md").contains("[C two](/d.md)"));
assert_eq!(block_on(w.retitle(Path::new("d.md"), "D")).unwrap(), 2);
assert_eq!(fs.doc_reads(&dir, "b.md"), 2, "a rename drops the index");
}
#[test]
fn an_out_of_band_edit_is_seen() {
let dir = tree("out-of-band");
let fs = CountingFs::default();
let mut w = ws(&dir, fs.clone());
assert_eq!(block_on(w.retitle(Path::new("c.md"), "C two")).unwrap(), 2);
write(
&dir,
"b.md",
"---\ntitle: B\npart_of: '[Root](index.md)'\nlinks:\n- '[C](c.md)'\n---\n",
);
assert_eq!(
block_on(w.retitle(Path::new("c.md"), "C three")).unwrap(),
3,
"the edit behind prov's back was not seen"
);
assert!(read(&dir, "b.md").contains("[C three](c.md)"));
}
#[test]
fn a_missing_child_appearing_is_seen() {
let dir = scratch("inbound", "absent-child");
write(
&dir,
"index.md",
"---\ntitle: Root\ncontents:\n- '[C](c.md)'\n- '[Late](late.md)'\n---\n",
);
write(
&dir,
"c.md",
"---\ntitle: C\npart_of: '[Root](index.md)'\n---\n",
);
let fs = CountingFs::default();
let mut w = ws(&dir, fs.clone());
assert_eq!(block_on(w.retitle(Path::new("c.md"), "C two")).unwrap(), 1);
write(
&dir,
"late.md",
"---\ntitle: Late\npart_of: '[Root](index.md)'\nlinks:\n- '[C](c.md)'\n---\n",
);
assert_eq!(
block_on(w.retitle(Path::new("c.md"), "C three")).unwrap(),
2,
"a spanning child that came into being was not censused"
);
assert!(read(&dir, "late.md").contains("[C three](c.md)"));
}
#[test]
fn writes_through_prov_are_seen() {
let dir = tree("create");
let fs = CountingFs::default();
let mut w = ws(&dir, fs.clone());
assert_eq!(block_on(w.retitle(Path::new("c.md"), "C two")).unwrap(), 2);
block_on(w.create_with_title(Path::new("d.md"), Path::new("index.md"), "D")).unwrap();
assert_eq!(
block_on(w.retitle(Path::new("c.md"), "C three")).unwrap(),
2,
"the new child links nowhere yet"
);
let text = read(&dir, "d.md");
let linked = text.replacen("title: D\n", "title: D\nlinks:\n- '[C](c.md)'\n", 1);
assert_ne!(linked, text);
block_on(w.save_document("d.md", &linked, None)).unwrap();
let before = fs.doc_reads(&dir, "b.md");
assert_eq!(
block_on(w.retitle(Path::new("c.md"), "C four")).unwrap(),
3,
"a link saved through prov was not seen"
);
assert_eq!(fs.doc_reads(&dir, "b.md"), before, "a save is not a census");
assert!(read(&dir, "d.md").contains("[C four](c.md)"));
}
}