use std::collections::BTreeSet;
use std::ops::Range;
use std::path::{Path, PathBuf};
use fig::Segment;
use crate::document::Document;
use crate::edit::MetaEditor;
use crate::error::{Error, Result};
use crate::fs::Storage;
use crate::identity::IdentityPolicy;
use crate::index::IndexStore;
use crate::link::{self, Link};
use crate::meta::Value;
use crate::validate::Resolution;
use crate::workspace::{Target, Workspace};
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) 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,
}
}
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(&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(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())
})
}
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 entries = value.link_strings();
let dir = doc_path.parent().unwrap_or(Path::new(""));
let Some(index) = entries.iter().position(|raw| {
self.resolve_link(doc_path, &Link::parse(raw)) == Target::Path(old.to_path_buf())
}) else {
return Ok(None);
};
let entry = Link::parse(&entries[index]);
if entry.id_target().is_some() {
return Ok(None);
}
let updated = entry.with_target(link::relative(dir, new));
let Some(carrier) = doc.carrier else {
return Ok(None); };
let mut editor = MetaEditor::open(text, carrier)?;
if value.as_sequence().is_some() {
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, doc0) = self.load(source).await?;
let mut text = rewrite_body_inbound(&original, &doc0.body, source, from, to);
let mut doc = if text != original {
Document::parse(source, &text)?
} else {
doc0
};
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_target(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 crate::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(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) -> String {
if body.is_empty() {
return text.to_string();
}
let source_dir = source.parent().unwrap_or(Path::new(""));
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.id_target().is_some() || bl.link.is_external() {
continue;
}
if link::resolve(source, &bl.link.target).as_path() != from {
continue;
}
let retargeted = bl.link.with_target(link::relative(source_dir, to)).render();
new_body.replace_range(bl.span.clone(), &retargeted);
changed = true;
}
if !changed {
return text.to_string();
}
splice_body(text, body, &new_body)
}