use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::content::{ContentFormat, transcode};
use crate::error::Result;
use crate::identity::Id;
use crate::link;
use crate::meta::{Mapping, Value};
use super::event_id::*;
use super::layout::*;
use super::model::*;
use super::paths::*;
use super::{BLOBS_DIR, EVENTS_DIR, FORGOTTEN_STEM, TRIGGER_MANUAL};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Authoring {
pub ext: String,
pub content: ContentFormat,
pub embed: fig::EmbedType,
}
pub(super) fn parse_event(path: &Path, id: &str, meta: &Value) -> Option<Event> {
let created = meta.get("created").and_then(Value::as_str)?.to_string();
let rows = meta.get("files").and_then(Value::as_sequence)?;
let mut files = Vec::with_capacity(rows.len());
for row in rows {
let (Some(p), Some(hash)) = (
row.get("path").and_then(Value::as_str),
row.get("hash").and_then(Value::as_str),
) else {
continue;
};
files.push(FileEntry {
path: link::normalize(p),
id: row
.get("id")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.map(|s| Id(s.trim().to_string())),
hash: hash.to_string(),
});
}
Some(Event {
id: id.to_string(),
path: path.to_path_buf(),
created,
trigger: meta
.get("trigger")
.and_then(Value::as_str)
.unwrap_or(TRIGGER_MANUAL)
.to_string(),
label: meta
.get("label")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.map(str::to_owned),
parent: meta
.get("parent")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.map(str::to_owned),
files,
})
}
pub(super) fn render_index(
title: &str,
up: Option<(&str, &str)>,
entries: &[(String, String)],
prose: &str,
style: &Authoring,
) -> Result<String> {
let mut map = Mapping::new();
map.insert("title".into(), Value::String(title.to_string()));
if let Some((label, target)) = up {
map.insert(
"part_of".into(),
Value::String(format!("[{label}]({target})")),
);
}
map.insert(
"contents".into(),
Value::Sequence(
entries
.iter()
.map(|(label, target)| Value::String(format!("[{label}]({target})")))
.collect(),
),
);
let body = transcode(&format!("# {title}\n\n{prose}\n"), style.content)?;
crate::edit::reformat_block(&body, &map, style.embed)
}
fn carrier_opening(embed: fig::EmbedType) -> String {
use fig::EmbedType as E;
let name = crate::about::format_name(embed.inner_format());
match embed {
E::FrontmatterYaml => format!("between the `---` lines at the top, written in {name}"),
E::FrontmatterJson => format!("between the `;;;` lines at the top, written in {name}"),
E::PlusToml => format!("between the `+++` lines at the top, written in {name}"),
E::MdFrontmatterJson | E::MdFrontmatterToml | E::MdFrontmatterFig => {
format!("between the `---` lines at the top, written in {name}")
}
E::FencedYaml | E::FencedJson | E::FencedToml | E::FrontmatterFig => {
format!("in the fenced code block at the top, written in {name}")
}
E::EndmatterYaml => format!("in the `endmatter` block at the *end*, written in {name}"),
E::HtmlScriptYaml | E::HtmlScriptJson | E::HtmlScriptToml | E::HtmlScriptFig => {
format!("inside the `<script>` tag at the top, written in {name}")
}
E::HtmlCodeYaml | E::HtmlCodeJson | E::HtmlCodeToml | E::HtmlCodeFig => format!(
"inside the `<pre><code>` block at the top, written in {name} \
(with `<` and `&` HTML-encoded)"
),
_ => unreachable!("unhandled fig::EmbedType variant — add a case in carrier_opening"),
}
}
pub(super) fn store_prose(style: &Authoring) -> String {
let paragraphs = [
crate::about::para(
"This directory is `prov`'s **history store**: a safety net for damage \
an external sync transport can do to the workspace's structure.",
),
crate::about::para(&format!(
"Each capture writes one immutable document under \
`{EVENTS_DIR}/<year>/<month>/`, recording the complete set of files \
that existed at that moment. That record is its `files` list, {} — one \
entry per file, giving the path, its SHA-256 content hash, and its id \
when it has one.",
carrier_opening(style.embed),
)),
crate::about::para(&format!(
"The bytes themselves live under `{BLOBS_DIR}/`, named by content hash \
and shared between captures, so identical content is stored once. A \
hash of `sha256:abcdef…` is the file `{BLOBS_DIR}/ab/cdef…` — first \
two hex characters for the directory, the remaining sixty-two for the \
filename, and never the `sha256:` prefix itself.",
)),
crate::about::para(
"**Recovering a file without prov.** A blob is the file: the exact \
bytes, uncompressed and unencoded. Find the path in an event's `files` \
list, take its hash, and copy that blob back over the file. \
`sha256sum` (or `shasum -a 256`) on a blob prints the name it is \
stored under, so anything here can be checked against its own \
filename with no other tool.",
),
crate::about::para(
"Nothing here is ever rewritten except these index files, which are a \
cache: the event documents are the authority, and any index can be \
rebuilt by listing the directory beneath it (`prov check` reports and \
repairs a stale one).",
),
crate::about::para(
"Capture a new event with `prov history-capture`; list what is here \
with `prov history-list`.",
),
];
paragraphs.join("\n")
}
pub(super) fn shard_title(id: &str) -> String {
match shard_of(id).ok().as_deref().map(shard_parts) {
Some(Ok((year, month))) => format!("{} {year}", month_name(&month)),
_ => "History".to_string(),
}
}
pub(super) fn month_name(month: &str) -> &str {
match month {
"01" => "January",
"02" => "February",
"03" => "March",
"04" => "April",
"05" => "May",
"06" => "June",
"07" => "July",
"08" => "August",
"09" => "September",
"10" => "October",
"11" => "November",
"12" => "December",
other => other,
}
}
pub(super) fn index_entries(index: &Path, meta: &Value) -> Vec<PathBuf> {
meta.get("contents")
.map(Value::link_strings)
.unwrap_or_default()
.iter()
.map(|raw| link::resolve(index, &crate::link::Link::parse(raw).target))
.collect()
}
pub(super) fn render_store_index(
years: &BTreeSet<String>,
forgotten: Option<&Path>,
style: &Authoring,
) -> Result<String> {
let ext = style.ext.as_str();
let mut entries: Vec<(String, String)> = years
.iter()
.map(|year| (year.clone(), format!("{EVENTS_DIR}/{year}/index.{ext}")))
.collect();
if let Some(path) = forgotten
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
entries.push(("Forgotten".into(), name.to_string()));
}
render_index("History", None, &entries, &store_prose(style), style)
}
pub(super) fn forgotten_hashes(meta: &Value) -> BTreeSet<String> {
meta.get(FORGOTTEN_STEM)
.and_then(Value::as_sequence)
.map(|rows| {
rows.iter()
.filter_map(|row| row.get("hash").and_then(Value::as_str))
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
pub(super) fn render_forgotten(
existing: Option<&Value>,
hashes: &BTreeSet<String>,
subject: &Subject,
now: &str,
format: fig::Format,
) -> Result<String> {
let mut rows: Vec<Value> = existing
.and_then(|meta| meta.get(FORGOTTEN_STEM))
.and_then(Value::as_sequence)
.map(<[Value]>::to_vec)
.unwrap_or_default();
let already: BTreeSet<String> = rows
.iter()
.filter_map(|row| row.get("hash").and_then(Value::as_str))
.map(str::to_owned)
.collect();
let named = match subject {
Subject::Id(id) => format!("id:{id}"),
Subject::Path(path) => slash_path(path),
};
for hash in hashes {
if already.contains(hash) {
continue;
}
let mut row = Mapping::new();
row.insert("hash".into(), Value::String(hash.clone()));
row.insert("at".into(), Value::String(now.to_string()));
row.insert("subject".into(), Value::String(named.clone()));
rows.push(Value::Mapping(row));
}
let mut map = Mapping::new();
map.insert("title".into(), Value::String("Forgotten".into()));
map.insert(FORGOTTEN_STEM.into(), Value::Sequence(rows));
crate::meta::serialize_mapping(&map, format)
}
pub(super) fn subject_matches(subject: &Subject, file: &FileEntry) -> bool {
match subject {
Subject::Id(id) => file.id.as_ref() == Some(id),
Subject::Path(path) => file.path == *path,
}
}
pub(super) fn render_year_index(
year: &str,
months: &BTreeSet<String>,
style: &Authoring,
) -> Result<String> {
let ext = style.ext.as_str();
let entries: Vec<(String, String)> = months
.iter()
.map(|month| {
(
format!("{} {year}", month_name(month)),
format!("{month}/index.{ext}"),
)
})
.collect();
render_index(
year,
Some(("History", &format!("../../index.{ext}"))),
&entries,
&format!("Captures taken during {year}, one directory per month."),
style,
)
}
pub(super) fn render_month_index(
year: &str,
month: &str,
ids: &BTreeSet<String>,
style: &Authoring,
) -> Result<String> {
let ext = style.ext.as_str();
let entries: Vec<(String, String)> = ids
.iter()
.map(|id| (display_entry(id), format!("{id}.{ext}")))
.collect();
let title = format!("{} {year}", month_name(month));
render_index(
&title,
Some((year, &format!("../index.{ext}"))),
&entries,
&format!(
"Every capture taken in {title}. Each entry is one immutable event \
document recording the complete file set at that moment."
),
style,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn style(content: ContentFormat, embed: fig::EmbedType) -> Authoring {
Authoring {
ext: content.extension().to_string(),
content,
embed,
}
}
#[test]
fn the_store_prose_names_the_carrier_the_reader_is_actually_looking_at() {
let flowed = |embed, content| {
store_prose(&style(content, embed))
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
};
let yaml = flowed(fig::EmbedType::FrontmatterYaml, ContentFormat::Markdown);
assert!(
yaml.contains("between the `---` lines at the top, written in YAML"),
"{yaml}"
);
let island = flowed(fig::EmbedType::HtmlScriptJson, ContentFormat::Html);
assert!(
island.contains("inside the `<script>` tag at the top, written in JSON"),
"{island}"
);
let fig_block = flowed(fig::EmbedType::FrontmatterFig, ContentFormat::Markdown);
assert!(
fig_block.contains("in the fenced code block at the top, written in fig"),
"{fig_block}"
);
for prose in [&yaml, &island, &fig_block] {
assert!(prose.contains("blobs/ab/cdef"), "{prose}");
assert!(prose.contains("A blob is the file"), "{prose}");
assert!(prose.contains("sha256sum"), "{prose}");
}
}
#[test]
fn an_html_store_index_is_html_all_the_way_down() {
let years = BTreeSet::from(["2026".to_string()]);
let html = render_store_index(
&years,
None,
&style(ContentFormat::Html, fig::EmbedType::HtmlScriptJson),
)
.unwrap();
assert!(
html.starts_with("<script type=\"application/json\">"),
"{html}"
);
assert!(html.contains("<h1>History</h1>"), "{html}");
assert!(html.contains("<p>"), "{html}");
assert!(
!html.contains("\n# History"),
"a Markdown heading leaked into an HTML document: {html}"
);
assert!(html.contains("events/2026/index.html"), "{html}");
}
}