use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use crate::document::{Document, MetaCarrier, require_whole_file, whole_file_format};
use crate::edit::MetaEditor;
use crate::error::Result;
use crate::identity::Id;
use crate::meta::{Mapping, Value};
pub trait IndexStore {
fn register(&mut self, id: &Id, path: &Path);
fn resolve(&self, id: &Id) -> Option<PathBuf>;
fn id_for_path(&self, path: &Path) -> Option<Id>;
fn set_path(&mut self, id: &Id, new_path: &Path);
fn unregister(&mut self, id: &Id);
fn is_known(&self, id: &Id) -> bool {
self.resolve(id).is_some()
}
fn checkpoint(&mut self) {}
fn rollback(&mut self) {}
fn committed(&mut self, persisted: bool) {
let _ = persisted;
}
fn rebase(&mut self, cs: &crate::change::ChangeSet) -> Result<()> {
let _ = cs;
Ok(())
}
fn pending_write(&mut self) -> Result<Option<(PathBuf, String)>> {
Ok(None)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoIndex;
impl IndexStore for NoIndex {
fn register(&mut self, _id: &Id, _path: &Path) {}
fn resolve(&self, _id: &Id) -> Option<PathBuf> {
None
}
fn id_for_path(&self, _path: &Path) -> Option<Id> {
None
}
fn set_path(&mut self, _id: &Id, _new_path: &Path) {}
fn unregister(&mut self, _id: &Id) {}
}
#[derive(Debug, Clone, Default)]
pub struct InMemoryIndex {
forward: HashMap<Id, PathBuf>,
reverse: HashMap<PathBuf, Id>,
saved: Option<Box<InMemoryState>>,
}
#[derive(Debug, Clone)]
struct InMemoryState {
forward: HashMap<Id, PathBuf>,
reverse: HashMap<PathBuf, Id>,
}
impl InMemoryIndex {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.forward.len()
}
pub fn is_empty(&self) -> bool {
self.forward.is_empty()
}
}
impl IndexStore for InMemoryIndex {
fn register(&mut self, id: &Id, path: &Path) {
self.forward.insert(id.clone(), path.to_path_buf());
self.reverse.insert(path.to_path_buf(), id.clone());
}
fn resolve(&self, id: &Id) -> Option<PathBuf> {
self.forward.get(id).cloned()
}
fn id_for_path(&self, path: &Path) -> Option<Id> {
self.reverse.get(path).cloned()
}
fn set_path(&mut self, id: &Id, new_path: &Path) {
if let Some(old) = self.forward.insert(id.clone(), new_path.to_path_buf()) {
self.reverse.remove(&old);
}
self.reverse.insert(new_path.to_path_buf(), id.clone());
}
fn unregister(&mut self, id: &Id) {
if let Some(path) = self.forward.remove(id) {
self.reverse.remove(&path);
}
}
fn checkpoint(&mut self) {
self.saved = Some(Box::new(InMemoryState {
forward: self.forward.clone(),
reverse: self.reverse.clone(),
}));
}
fn rollback(&mut self) {
if let Some(saved) = self.saved.take() {
self.forward = saved.forward;
self.reverse = saved.reverse;
}
}
fn committed(&mut self, _persisted: bool) {
self.saved = None;
}
}
#[derive(Debug, Clone)]
pub struct FileIndex {
live: InMemoryIndex,
tombstones: BTreeSet<Id>,
host: Option<PathBuf>,
host_text: String,
carrier: MetaCarrier,
persisted: BTreeMap<Id, Option<String>>,
has_registry_key: bool,
dirty: bool,
saved: Option<Box<FileIndexState>>,
}
#[derive(Debug, Clone)]
struct FileIndexState {
live: InMemoryIndex,
tombstones: BTreeSet<Id>,
host_text: String,
persisted: BTreeMap<Id, Option<String>>,
has_registry_key: bool,
dirty: bool,
}
impl FileIndex {
pub fn new(format: fig::Format) -> Self {
Self {
live: InMemoryIndex::new(),
tombstones: BTreeSet::new(),
host: None,
host_text: String::new(),
carrier: MetaCarrier::WholeFile(format),
persisted: BTreeMap::new(),
has_registry_key: false,
dirty: false,
saved: None,
}
}
pub fn set_host(&mut self, path: impl Into<PathBuf>, text: &str) -> Result<()> {
let path = path.into();
let reparsed = Self::parse(&path, text)?;
self.host = Some(path);
self.carrier = reparsed.carrier;
self.host_text = reparsed.host_text;
self.persisted = reparsed.persisted;
self.has_registry_key = reparsed.has_registry_key;
Ok(())
}
pub fn host(&self) -> Option<&Path> {
self.host.as_deref()
}
pub fn parse(path: &Path, text: &str) -> Result<Self> {
let doc = Document::parse(path, text)?;
let carrier = doc.carrier.unwrap_or_else(|| {
whole_file_format(path)
.map(MetaCarrier::WholeFile)
.unwrap_or(MetaCarrier::Fenced(fig::EmbedType::FrontmatterYaml))
});
require_whole_file(path, carrier)?;
let mut index = Self {
live: InMemoryIndex::new(),
tombstones: BTreeSet::new(),
host: Some(path.to_path_buf()),
host_text: text.to_string(),
carrier,
persisted: BTreeMap::new(),
has_registry_key: doc.meta.get("registry").is_some(),
dirty: false,
saved: None,
};
if let Some(registry) = doc.meta.get("registry").and_then(Value::as_mapping) {
for (id, value) in registry {
let id = Id(id.clone());
match value {
Value::Null => {
index.persisted.insert(id.clone(), None);
index.tombstones.insert(id);
}
Value::String(path) => {
index.persisted.insert(id.clone(), Some(path.clone()));
index.live.register(&id, Path::new(path));
}
_ => {
return Err(crate::error::Error::Structure(format!(
"registry entry `{id}` must be a path or null (tombstone)"
)));
}
}
}
}
Ok(index)
}
pub fn render(&mut self) -> Result<String> {
let mut current: BTreeMap<Id, Option<String>> = BTreeMap::new();
for id in &self.tombstones {
current.insert(id.clone(), None);
}
for (id, path) in &self.live.forward {
current.insert(id.clone(), Some(path.to_string_lossy().into_owned()));
}
if current == self.persisted {
return Ok(self.host_text.clone());
}
if !self.has_registry_key {
let mut registry = Mapping::new();
for (id, value) in ¤t {
registry.insert(
id.0.clone(),
value.clone().map(Value::String).unwrap_or(Value::Null),
);
}
let rendered = match self.carrier {
MetaCarrier::WholeFile(format) => {
let mut top = crate::meta::parse_mapping(&self.host_text, format)?;
top.insert("registry".into(), Value::Mapping(registry));
crate::meta::serialize_mapping(&top, format)?
}
MetaCarrier::Fenced(_) => {
return Err(crate::error::Error::MarkdownStore(
self.host.clone().unwrap_or_default(),
));
}
};
self.host_text = rendered.clone();
self.persisted = current;
self.has_registry_key = true;
return Ok(rendered);
}
let mut editor = MetaEditor::open_or_init(&self.host_text, Some(self.carrier))?;
for (id, value) in ¤t {
if self.persisted.get(id) == Some(value) {
continue;
}
let fig_value = value
.clone()
.map(fig::Value::Str)
.unwrap_or(fig::Value::Null);
editor.set_value(
&[
fig::Segment::Key("registry"),
fig::Segment::Key(id.as_str()),
],
fig_value,
)?;
}
let rendered = editor.render()?;
self.host_text = rendered.clone();
self.persisted = current;
Ok(rendered)
}
pub fn is_dirty(&self) -> bool {
self.dirty
}
pub fn mark_clean(&mut self) {
self.dirty = false;
}
pub fn len(&self) -> usize {
self.live.len()
}
pub fn is_empty(&self) -> bool {
self.live.is_empty()
}
pub fn is_tombstoned(&self, id: &Id) -> bool {
self.tombstones.contains(id)
}
pub fn iter(&self) -> impl Iterator<Item = (&Id, &PathBuf)> {
let mut live: Vec<_> = self.live.forward.iter().collect();
live.sort_by(|a, b| a.0.cmp(b.0));
live.into_iter()
}
}
impl IndexStore for FileIndex {
fn register(&mut self, id: &Id, path: &Path) {
self.live.register(id, path);
self.dirty = true;
}
fn resolve(&self, id: &Id) -> Option<PathBuf> {
self.live.resolve(id)
}
fn id_for_path(&self, path: &Path) -> Option<Id> {
self.live.id_for_path(path)
}
fn set_path(&mut self, id: &Id, new_path: &Path) {
self.live.set_path(id, new_path);
self.dirty = true;
}
fn unregister(&mut self, id: &Id) {
self.live.unregister(id);
self.tombstones.insert(id.clone());
self.dirty = true;
}
fn is_known(&self, id: &Id) -> bool {
self.live.resolve(id).is_some() || self.tombstones.contains(id)
}
fn checkpoint(&mut self) {
self.saved = Some(Box::new(FileIndexState {
live: self.live.clone(),
tombstones: self.tombstones.clone(),
host_text: self.host_text.clone(),
persisted: self.persisted.clone(),
has_registry_key: self.has_registry_key,
dirty: self.dirty,
}));
}
fn rollback(&mut self) {
let Some(saved) = self.saved.take() else {
return;
};
let FileIndexState {
live,
tombstones,
host_text,
persisted,
has_registry_key,
dirty,
} = *saved;
self.live = live;
self.tombstones = tombstones;
self.host_text = host_text;
self.persisted = persisted;
self.has_registry_key = has_registry_key;
self.dirty = dirty;
}
fn committed(&mut self, persisted: bool) {
self.saved = None;
if persisted {
self.dirty = false;
}
}
fn rebase(&mut self, cs: &crate::change::ChangeSet) -> Result<()> {
let Some(host) = self.host.clone() else {
return Ok(());
};
let dest = cs.renamed_to(&host).unwrap_or(host);
if let Some(bytes) = cs.staged(&dest) {
let text = String::from_utf8(bytes.to_vec()).map_err(|e| {
crate::error::Error::Structure(format!(
"{} is not valid UTF-8: {e}",
dest.display()
))
})?;
return self.set_host(dest, &text);
}
self.host = Some(dest);
Ok(())
}
fn pending_write(&mut self) -> Result<Option<(PathBuf, String)>> {
if !self.dirty {
return Ok(None);
}
let Some(host) = self.host.clone() else {
return Ok(None);
};
Ok(Some((host, self.render()?)))
}
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::*;
#[test]
fn set_host_keeps_this_stores_records_and_preserves_the_hosts() {
let mut ix = FileIndex::new(fig::Format::Yaml);
let mine = Id("mineeee".into());
ix.register(&mine, Path::new("fixed.md"));
let host = "title: ID registry\npart_of: index.md\nregistry:\n theirss: other.md\n";
ix.set_host("registry.yaml", host).unwrap();
let (path, rendered) = ix
.pending_write()
.unwrap()
.expect("dirty, and now has a home");
assert_eq!(path, PathBuf::from("registry.yaml"));
assert!(
rendered.contains("fixed.md"),
"this store's record must land: {rendered}"
);
assert!(
rendered.contains("other.md"),
"the host's record must survive: {rendered}"
);
assert!(
rendered.contains("part_of"),
"the host's self-description survives: {rendered}"
);
assert_eq!(ix.resolve(&Id("theirss".into())), None);
let reread = FileIndex::parse(Path::new("registry.yaml"), &rendered).unwrap();
assert_eq!(reread.resolve(&mine), Some(PathBuf::from("fixed.md")));
assert_eq!(
reread.resolve(&Id("theirss".into())),
Some(PathBuf::from("other.md"))
);
}
#[test]
fn a_store_with_no_host_stays_dirty_rather_than_dropping_records() {
let mut ix = FileIndex::new(fig::Format::Yaml);
ix.register(&Id("orphann".into()), Path::new("a.md"));
assert_eq!(ix.pending_write().unwrap(), None, "nowhere to write");
assert!(ix.is_dirty(), "and so it must not claim to be persisted");
}
#[test]
fn registers_and_resolves_both_directions() {
let mut ix = InMemoryIndex::new();
let id = Id("ajp7eq".into());
ix.register(&id, Path::new("notes/a.md"));
assert_eq!(ix.resolve(&id), Some(PathBuf::from("notes/a.md")));
assert_eq!(ix.id_for_path(Path::new("notes/a.md")), Some(id.clone()));
assert_eq!(ix.len(), 1);
}
#[test]
fn move_updates_path_and_clears_stale_reverse() {
let mut ix = InMemoryIndex::new();
let id = Id("ajp7eq".into());
ix.register(&id, Path::new("a.md"));
ix.set_path(&id, Path::new("moved/a.md"));
assert_eq!(ix.resolve(&id), Some(PathBuf::from("moved/a.md")));
assert_eq!(ix.id_for_path(Path::new("a.md")), None);
assert_eq!(ix.id_for_path(Path::new("moved/a.md")), Some(id));
}
#[test]
fn unregister_removes_both_directions() {
let mut ix = InMemoryIndex::new();
let id = Id("x".into());
ix.register(&id, Path::new("a.md"));
ix.unregister(&id);
assert!(ix.is_empty());
assert_eq!(ix.id_for_path(Path::new("a.md")), None);
}
#[test]
fn file_index_round_trips_sorted_with_tombstones() {
let mut ix = FileIndex::new(fig::Format::Yaml);
ix.register(&Id("zzzzzzz".into()), Path::new("z.md"));
ix.register(&Id("bcdfghj".into()), Path::new("notes/a.md"));
ix.register(&Id("mmmmmmm".into()), Path::new("gone.md"));
ix.unregister(&Id("mmmmmmm".into()));
let text = ix.render().unwrap();
let b = text.find("bcdfghj").unwrap();
let m = text.find("mmmmmmm").unwrap();
let z = text.find("zzzzzzz").unwrap();
assert!(b < m && m < z, "{text}");
assert!(text.contains("mmmmmmm: null"), "{text}");
let back = FileIndex::parse(Path::new("registry.yaml"), &text).unwrap();
assert_eq!(
back.resolve(&Id("bcdfghj".into())),
Some(PathBuf::from("notes/a.md"))
);
assert_eq!(back.resolve(&Id("mmmmmmm".into())), None);
assert!(
back.is_known(&Id("mmmmmmm".into())),
"tombstone survives the round-trip"
);
assert!(back.is_tombstoned(&Id("mmmmmmm".into())));
assert!(!back.is_dirty());
}
#[test]
fn registry_host_keeps_its_self_description_and_comments() {
let host = "# who am I? see title
title: ID registry
part_of: index.md
registry:
bcdfghj: a.md
";
let mut ix = FileIndex::parse(Path::new("registry.yaml"), host).unwrap();
ix.register(&Id("zzzzzzz".into()), Path::new("z.md"));
let out = ix.render().unwrap();
assert!(out.contains("# who am I? see title"), "{out}");
assert!(out.contains("title: ID registry"), "{out}");
assert!(out.contains("part_of: index.md"), "{out}");
assert!(out.contains("bcdfghj: a.md"), "{out}");
assert!(out.contains("zzzzzzz: z.md"), "{out}");
}
#[test]
fn a_markdown_carrier_registry_is_refused() {
let host = "---
title: Registry
part_of: index.md
registry:
bcdfghj: a.md
---
# About this file
Prose does not belong in a record store.
";
let err = FileIndex::parse(Path::new("registry.md"), host).unwrap_err();
assert!(
matches!(err, crate::error::Error::MarkdownStore(_)),
"expected MarkdownStore, got {err:?}"
);
}
#[test]
fn tombstoned_ids_are_never_free_for_reminting() {
let mut ix = FileIndex::new(fig::Format::Yaml);
let id = Id("bcdfghj".into());
ix.register(&id, Path::new("a.md"));
ix.unregister(&id);
assert_eq!(ix.resolve(&id), None, "does not resolve");
assert!(ix.is_known(&id), "but is still known — never reminted");
}
#[test]
fn dirty_tracks_mutations() {
let mut ix = FileIndex::new(fig::Format::Yaml);
assert!(!ix.is_dirty());
ix.register(&Id("x".into()), Path::new("a.md"));
assert!(ix.is_dirty());
ix.mark_clean();
assert!(!ix.is_dirty());
}
#[test]
fn empty_text_is_an_empty_registry() {
let ix = FileIndex::parse(Path::new("registry.yaml"), "").unwrap();
assert!(ix.is_empty());
}
}