use std::collections::{BTreeMap, BTreeSet};
use std::ops::Range;
use std::path::{Path, PathBuf};
use fig::Segment;
use crate::identity::IdentityPolicy;
use crate::validate::Finding;
use crate::workspace::Workspace;
use super::delete::Diagnosis;
use prov_graph::document::{Document, whole_file_format};
use prov_graph::error::{Error, Result};
use prov_graph::graph::{LinkSite, Resolution, Target};
use prov_graph::link::{self, Link, LinkStyle};
use prov_graph::meta::Value;
use prov_store::edit::MetaEditor;
use prov_store::fs::Storage;
use prov_store::index::IndexStore;
impl<FS: Storage, IdP, Ix: IndexStore> Workspace<FS, IdP, Ix> {
pub(crate) fn spanning_pair(&self) -> Result<(String, String)> {
let spanning = self
.relations()
.spanning_relation()
.ok_or_else(|| Error::Structure("no spanning relation configured".into()))?;
let inverse = self
.relations()
.relations()
.iter()
.find(|r| r.name == spanning)
.and_then(|r| r.inverse.clone())
.ok_or_else(|| {
Error::Structure(format!("spanning relation `{spanning}` has no inverse"))
})?;
Ok((spanning.to_string(), inverse))
}
pub(crate) fn single_target(
&self,
doc: &Document,
field: &str,
doc_path: &Path,
) -> Option<PathBuf> {
let raw = doc
.meta
.get(field)
.map(Value::link_strings)?
.into_iter()
.next()?;
match self.resolve_link(doc_path, &Link::parse(&raw)) {
Target::Path(p) => Some(p),
_ => None,
}
}
pub(crate) fn entry_index(
&self,
doc: &Document,
field: &str,
doc_path: &Path,
wanted: &Path,
) -> Option<usize> {
doc.meta
.get(field)
.map(Value::link_strings)?
.iter()
.position(|raw| {
self.resolve_link(doc_path, &Link::parse(raw)) == Target::Path(wanted.to_path_buf())
})
}
pub(crate) async fn spanning_root(&self, from: &Path, inverse: &str) -> Result<PathBuf> {
let mut current = from.to_path_buf();
let mut seen = BTreeSet::new();
while seen.insert(current.clone()) {
let Ok((_, doc)) = self.load(¤t).await else {
break;
};
match self.single_target(&doc, inverse, ¤t) {
Some(parent) => current = parent,
None => break,
}
}
if current == from
&& let Some(root) = self.root_document().await?
&& root != current
{
return Ok(root);
}
Ok(current)
}
}
impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
pub(super) async fn spanning_subtree(&self, root: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
let mut seen = BTreeSet::new();
let mut queue = vec![root.to_path_buf()];
while let Some(path) = queue.pop() {
if !seen.insert(path.clone()) {
continue;
}
let Ok((_, doc)) = self.load(&path).await else {
continue;
};
out.push(path.clone());
for raw in self.relations().children(&fig::Value::from(&doc.meta)) {
if let Target::Path(child) = self.resolve_link(&path, &Link::parse(&raw)) {
queue.push(child);
}
}
}
Ok(out)
}
pub(super) async fn collect_inbound_rewrites(
&self,
from: &Path,
to: &Path,
) -> Result<Vec<(PathBuf, String)>> {
let (_spanning, inverse) = self.spanning_pair()?;
let root = self.spanning_root(from, &inverse).await?;
let mut sources: BTreeSet<PathBuf> = self
.census(&root)
.await?
.into_iter()
.filter(|e| {
matches!(&e.resolution,
Resolution::Path(p) | Resolution::CaseMismatch { got: p, .. } if p == from)
})
.map(|e| e.source)
.collect();
sources.remove(from);
let mut writes = Vec::new();
for source in sources {
if let Some(updated) = self.rewrite_inbound_doc(&source, from, to).await? {
writes.push((source, updated));
}
}
Ok(writes)
}
pub(super) async fn collect_inbound_rewrites_multi(
&self,
root: &Path,
moves: &BTreeMap<PathBuf, PathBuf>,
) -> Result<BTreeMap<PathBuf, String>> {
let mut by_source: BTreeMap<PathBuf, BTreeSet<PathBuf>> = BTreeMap::new();
for entry in self.census(root).await? {
let (Resolution::Path(p) | Resolution::CaseMismatch { got: p, .. }) = &entry.resolution
else {
continue;
};
if moves.contains_key(p) && &entry.source != p {
by_source.entry(entry.source.clone()).or_default();
by_source.get_mut(&entry.source).unwrap().insert(p.clone());
}
}
let mut writes = BTreeMap::new();
for (source, froms) in by_source {
let (original, mut doc) = self.load(&source).await?;
let mut text = original.clone();
for from in &froms {
if let Some(updated) =
self.rewrite_inbound_text(&source, &text, &doc, from, &moves[from])?
{
doc = Document::parse(&source, &updated)?;
text = updated;
}
}
if text != original {
writes.insert(source, text);
}
}
Ok(writes)
}
fn retarget_entry(
&self,
text: &str,
doc: &Document,
field: &str,
doc_path: &Path,
old: &Path,
new: &Path,
) -> Result<Option<String>> {
let Some(value) = doc.meta.get(field) else {
return Ok(None);
};
let matches = |raw: &str| {
let link = Link::parse(raw);
link.is_path_target()
&& self.resolve_link(doc_path, &link) == Target::Path(old.to_path_buf())
};
let hits: Vec<(usize, String)> = match value.as_sequence() {
Some(items) => items
.iter()
.enumerate()
.filter_map(|(i, item)| item.as_str().map(|raw| (i, raw.to_string())))
.filter(|(_, raw)| matches(raw))
.collect(),
None => value
.as_str()
.filter(|raw| matches(raw))
.map(|raw| vec![(0, raw.to_string())])
.unwrap_or_default(),
};
if hits.is_empty() {
return Ok(None);
}
let Some(carrier) = doc.carrier else {
return Ok(None); };
let is_sequence = value.as_sequence().is_some();
let style = self.reference_style_for(field).path_style;
let mut editor = MetaEditor::open(text, carrier)?;
for (index, raw) in hits {
let updated = Link::parse(&raw).with_path(link::path_text(style, doc_path, new));
if is_sequence {
editor.replace_value(
&[Segment::Key(field), Segment::Index(index)],
fig::Value::Str(updated.render()),
)?;
} else {
editor.replace_value(&[Segment::Key(field)], fig::Value::Str(updated.render()))?;
}
}
Ok(Some(editor.render()?))
}
async fn rewrite_inbound_doc(
&self,
source: &Path,
from: &Path,
to: &Path,
) -> Result<Option<String>> {
let (original, doc) = self.load(source).await?;
self.rewrite_inbound_text(source, &original, &doc, from, to)
}
fn rewrite_inbound_text(
&self,
source: &Path,
original: &str,
doc0: &Document,
from: &Path,
to: &Path,
) -> Result<Option<String>> {
let mut text =
rewrite_body_inbound(original, &doc0.body, source, from, to, self.link_style());
let mut doc = if text != original {
Document::parse(source, &text)?
} else {
doc0.clone()
};
for relation in self.relations().relations() {
if let Some(updated) =
self.retarget_entry(&text, &doc, &relation.name, source, from, to)?
{
text = updated;
doc = Document::parse(source, &text)?;
}
}
Ok((text != original).then_some(text))
}
}
pub(crate) fn written_entry_index(doc: &Document, field: &str, written: &str) -> Option<usize> {
let matches = |raw: &str| Link::parse(raw).target == written;
match doc.meta.get(field)? {
Value::Sequence(items) => items
.iter()
.position(|item| item.as_str().is_some_and(matches)),
other => other.as_str().is_some_and(matches).then_some(0),
}
}
fn entry_address<'a>(doc: &Document, field: &'a str, index: usize) -> Vec<Segment<'a>> {
match doc.meta.get(field).and_then(Value::as_sequence) {
Some(_) => vec![Segment::Key(field), Segment::Index(index)],
None => vec![Segment::Key(field)],
}
}
pub(crate) fn remove_written_entry(
text: &str,
doc: &Document,
field: &str,
written: &str,
) -> Result<Option<String>> {
let (Some(index), Some(carrier)) = (written_entry_index(doc, field, written), doc.carrier)
else {
return Ok(None);
};
let address = entry_address(doc, field, index);
let mut editor = MetaEditor::open(text, carrier)?;
if address.len() == 1 {
editor.delete(&address)?;
} else {
editor.remove_item(&[Segment::Key(field)], index)?;
}
Ok(Some(editor.render()?))
}
pub(crate) fn replace_written_entry(
text: &str,
doc: &Document,
field: &str,
written: &str,
replacement: &str,
) -> Result<Option<String>> {
let (Some(index), Some(carrier)) = (written_entry_index(doc, field, written), doc.carrier)
else {
return Ok(None);
};
let mut editor = MetaEditor::open(text, carrier)?;
editor.replace_value(
&entry_address(doc, field, index),
fig::Value::Str(replacement.to_string()),
)?;
Ok(Some(editor.render()?))
}
pub(crate) fn retarget_written_entry(
text: &str,
doc: &Document,
field: &str,
written: &str,
new_target: &str,
) -> Result<Option<String>> {
let index = written_entry_index(doc, field, written);
let raw = match (index, doc.meta.get(field)) {
(Some(i), Some(Value::Sequence(items))) => items.get(i).and_then(Value::as_str),
(Some(_), Some(other)) => other.as_str(),
_ => None,
};
let Some(raw) = raw else { return Ok(None) };
let rendered = Link::parse(raw).with_path(new_target.to_string()).render();
replace_written_entry(text, doc, field, written, &rendered)
}
pub(crate) fn splice_body_span(
text: &str,
body: &str,
span: &Range<usize>,
expected: &str,
replacement: &str,
) -> Result<String> {
if span.end > body.len() || body.get(span.clone()) != Some(expected) {
return Err(Error::Structure(format!(
"the document changed since it was checked — expected {expected:?} in the body, \
found something else; re-run `check` and repair from a fresh reading"
)));
}
let mut new_body = body.to_string();
new_body.replace_range(span.clone(), replacement);
Ok(splice_body(text, body, &new_body))
}
pub(super) fn body_sibling(node_to: &Path, body_from: &Path) -> (PathBuf, String) {
let body_to = if prov_graph::document::is_opaque_payload(body_from) {
let stem = node_to
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default();
node_to.with_file_name(stem)
} else {
let ext = body_from
.extension()
.and_then(|e| e.to_str())
.unwrap_or("md");
node_to.with_extension(ext)
};
let new_ref = body_to
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
(body_to, new_ref)
}
pub(super) fn content_target(doc: &Document, doc_path: &Path) -> Option<PathBuf> {
let raw = doc.content_attr()?;
let dir = doc_path.parent().unwrap_or(Path::new(""));
Some(link::normalize(dir.join(raw)))
}
pub(super) fn manifest_target(doc: &Document, doc_path: &Path) -> Option<PathBuf> {
let raw = doc.manifest_attr()?;
let dir = doc_path.parent().unwrap_or(Path::new(""));
Some(link::normalize(dir.join(raw)))
}
pub(super) fn paired_file(doc: &Document, doc_path: &Path) -> Option<PathBuf> {
content_target(doc, doc_path).or_else(|| manifest_target(doc, doc_path))
}
impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
pub(super) async fn content_owner(&self, body: &Path) -> Result<Option<PathBuf>> {
let dir = body.parent().unwrap_or(Path::new("")).to_path_buf();
let neighbourhood = BTreeSet::from([dir]);
for node in self.direct_child_files(&neighbourhood).await? {
if node == body || whole_file_format(&node).is_none() {
continue;
}
let Ok((_, doc)) = self.load(&node).await else {
continue;
};
if content_target(&doc, &node).as_deref() == Some(body) {
return Ok(Some(node));
}
}
Ok(None)
}
pub(super) async fn removal_danglers(
&self,
diagnosis: Diagnosis,
path: &Path,
parent: Option<&Path>,
owner: Option<&Path>,
) -> Result<Vec<Finding>> {
if diagnosis == Diagnosis::Skip {
return Ok(Vec::new());
}
let (spanning, inverse) = self.spanning_pair()?;
let root = self.spanning_root(path, &inverse).await?;
let mut danglers: Vec<Finding> = self
.census(&root)
.await?
.into_iter()
.filter(|e| e.resolution.resolved_path().map(PathBuf::as_path) == Some(path))
.filter(|e| {
e.source != path
&& !(Some(e.source.as_path()) == parent
&& matches!(&e.site, LinkSite::Relation(r) if *r == spanning))
})
.map(|e| match e.resolution {
Resolution::Id { id, .. } => Finding::DanglingId {
doc: e.source,
site: e.site,
id,
tombstoned: true,
},
_ => Finding::BrokenLink {
doc: e.source,
site: e.site,
target: e.target_text,
},
})
.collect();
if let Some(owner) = owner {
let target = self
.load(owner)
.await
.ok()
.and_then(|(_, doc)| doc.content_attr().map(str::to_string))
.unwrap_or_else(|| path.to_string_lossy().into_owned());
danglers.push(Finding::BrokenLink {
doc: owner.to_path_buf(),
site: LinkSite::Relation("content".to_string()),
target,
});
}
Ok(danglers)
}
}
pub(crate) fn splice_body(text: &str, old_body: &str, new_body: &str) -> String {
if let Some(head) = text.strip_suffix(old_body) {
format!("{head}{new_body}")
} else if let Some(tail) = text.strip_prefix(old_body) {
format!("{new_body}{tail}")
} else {
text.replacen(old_body, new_body, 1)
}
}
fn rewrite_body_inbound(
text: &str,
body: &str,
source: &Path,
from: &Path,
to: &Path,
style: LinkStyle,
) -> String {
if body.is_empty() {
return text.to_string();
}
let mut new_body = body.to_string();
let mut changed = false;
for bl in link::scan_body_links(source, body).into_iter().rev() {
if !bl.is_path_target() {
continue;
}
if link::resolve(source, &bl.link.target).as_path() != from {
continue;
}
let retargeted = bl
.link
.with_path(link::path_text(style, source, to))
.render();
new_body.replace_range(bl.span.clone(), &retargeted);
changed = true;
}
if !changed {
return text.to_string();
}
splice_body(text, body, &new_body)
}