use std::{
collections::HashSet,
path::{Path, PathBuf},
};
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::{
deck::{AtRewrite, Deck, DeckError},
source::{CitationIntegrity, Excerpt, SourceBase},
};
pub const ROOT: &str = "assets";
const DIGEST_PREFIX: &str = "sha256-";
#[derive(Debug, Error)]
pub enum AssetError {
#[error("deck ID `{0}` is not valid")]
InvalidDeckId(String),
#[error("asset name `{0}` is not content-addressed")]
InvalidName(String),
#[error("asset `{path}` does not match its content address")]
DigestMismatch { path: PathBuf },
#[error("{0} is not a member of an Alix workspace")]
NotWorkspaceMember(PathBuf),
#[error("{0} has no stable deck ID")]
MissingDeckId(PathBuf),
#[error("source `{0}` is missing or is not a regular file or directory")]
MissingSource(PathBuf),
#[error("frozen asset `{0}` is missing")]
MissingAsset(PathBuf),
#[error("source `{0}` is not UTF-8 text")]
NonTextSource(PathBuf),
#[error("citation `{locator}` is outside every declared source")]
CitationOutsideSource { locator: String },
#[error("cannot freeze citation `{locator}`: {message}")]
Citation { locator: String, message: String },
#[error("image `{0}` is outside the workspace and declared source boundaries")]
ImageOutsideBoundary(PathBuf),
#[error("image `{0}` is missing or is not a regular file")]
MissingImage(PathBuf),
#[error("{path}: {source}")]
Deck {
path: PathBuf,
#[source]
source: DeckError,
},
#[error("{path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Error)]
pub enum InitializeError {
#[error("cannot read {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(transparent)]
Stamp(#[from] crate::stamp::StampError),
#[error(transparent)]
Freeze(#[from] AssetError),
#[error("cannot restore {path} after initialization failed: {source}")]
Restore {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path} is a personal file, not a deck; it belongs to the deck beside it")]
Personal { path: PathBuf },
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct FreezeReport {
pub evidence: usize,
pub images: usize,
pub diagrams: usize,
pub diagram_warnings: Vec<String>,
}
#[derive(Debug)]
pub struct InitializeReport {
pub stamp: crate::stamp::StampOutcome,
pub freeze: Option<FreezeReport>,
}
enum SourceInput {
File { path: PathBuf },
Directory { path: PathBuf },
}
pub fn deck_dir(workspace_root: &Path, deck_id: &str) -> Result<PathBuf, AssetError> {
if !matches!(
crate::token::parse_id(deck_id),
Some((crate::token::Kind::Deck, ..))
) {
return Err(AssetError::InvalidDeckId(deck_id.to_string()));
}
Ok(crate::workspace::WorkspaceFiles::new(workspace_root).assets_for(deck_id))
}
pub fn normalized_extension(path: &Path, text: bool) -> String {
let extension = path
.extension()
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase)
.filter(|value| {
!value.is_empty()
&& value
.chars()
.all(|character| character.is_ascii_alphanumeric())
});
extension.unwrap_or_else(|| if text { "txt" } else { "bin" }.to_string())
}
pub fn object_name(bytes: &[u8], extension: &str) -> String {
let digest = Sha256::digest(bytes);
format!(
"{DIGEST_PREFIX}{}.{extension}",
digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
)
}
pub fn write_object(
workspace_root: &Path,
deck_id: &str,
bytes: &[u8],
extension: &str,
) -> Result<PathBuf, AssetError> {
let directory = deck_dir(workspace_root, deck_id)?;
let name = object_name(bytes, extension);
let path = directory.join(&name);
if path.is_file() {
verify_object(&path)?;
return Ok(path);
}
std::fs::create_dir_all(&directory).map_err(|source| AssetError::Io {
path: directory.clone(),
source,
})?;
let tmp = directory.join(format!(".{name}.tmp"));
crate::fsio::replace_file(&tmp, &path, bytes).map_err(|source| AssetError::Io {
path: path.clone(),
source,
})?;
Ok(path)
}
pub fn freeze_member(path: &Path) -> Result<FreezeReport, AssetError> {
freeze_member_with(path, default_diagram_command())
}
#[cfg(feature = "full")]
fn default_diagram_command() -> Option<&'static str> {
Some(crate::diagram::COMMAND)
}
#[cfg(not(feature = "full"))]
fn default_diagram_command() -> Option<&'static str> {
None
}
pub(crate) fn freeze_member_with(
path: &Path,
diagram_command: Option<&str>,
) -> Result<FreezeReport, AssetError> {
let workspace_root = crate::workspace::root_for_deck(path)
.ok_or_else(|| AssetError::NotWorkspaceMember(path.to_path_buf()))?;
let (_, _, defaults, _) =
crate::workspace::read_manifest(&workspace_root.join(crate::workspace::MANIFEST));
let deck = Deck::load_with_defaults(path, &defaults).map_err(|source| AssetError::Deck {
path: path.to_path_buf(),
source,
})?;
let deck_id = deck
.deck_token
.as_deref()
.ok_or_else(|| AssetError::MissingDeckId(path.to_path_buf()))?;
let owned_dir = deck_dir(&workspace_root, deck_id)?;
let owned_dir_existed = owned_dir.exists();
let root_existed = workspace_root.join(ROOT).exists();
let result = freeze_member_inner(&workspace_root, &deck, diagram_command);
if result.is_err() && !owned_dir_existed {
let _ = std::fs::remove_dir_all(&owned_dir);
if !root_existed {
let _ = std::fs::remove_dir(workspace_root.join(ROOT));
}
}
result
}
pub fn initialize(path: &Path) -> Result<InitializeReport, InitializeError> {
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(crate::workspace::is_sidecar_name)
{
return Err(InitializeError::Personal {
path: path.to_path_buf(),
});
}
let original = std::fs::read(path).map_err(|source| InitializeError::Read {
path: path.to_path_buf(),
source,
})?;
let stamp = crate::stamp::stamp_deck(path)?;
let freeze = if crate::workspace::root_for_deck(path).is_some() {
match freeze_member(path) {
Ok(report) => Some(report),
Err(error) => {
let tmp = path.with_extension("md.restore.tmp");
crate::fsio::replace_file(&tmp, path, &original).map_err(|source| {
InitializeError::Restore {
path: path.to_path_buf(),
source,
}
})?;
return Err(error.into());
}
}
} else {
None
};
Ok(InitializeReport { stamp, freeze })
}
fn freeze_member_inner(
workspace_root: &Path,
deck: &Deck,
diagram_command: Option<&str>,
) -> Result<FreezeReport, AssetError> {
let text = read_text(&deck.path)?;
let parsed = crate::parser::parse(&deck.subject, &text).map_err(|source| AssetError::Deck {
path: deck.path.clone(),
source: DeckError::Parse {
path: deck.path.clone(),
source,
},
})?;
let deck_id = deck
.deck_token
.as_deref()
.ok_or_else(|| AssetError::MissingDeckId(deck.path.clone()))?;
if deck_dir(workspace_root, deck_id)?.is_dir() {
validate_owned_dir(workspace_root, deck_id)?;
}
let inputs = declared_source_inputs(workspace_root, deck)?;
let source_base = SourceBase::for_deck(deck);
let mut evidence: Vec<String> = Vec::new();
let mut evidence_seen: HashSet<String> = HashSet::new();
let mut citations = Vec::new();
for card in &deck.cards {
for citation in &card.citations {
if let Some(asset) = &citation.asset {
if evidence_seen.insert(asset.clone()) {
evidence.push(asset.clone());
}
continue;
}
let excerpt = citation_excerpt(&source_base, citation)?;
let input_index = source_owner(&inputs, &excerpt).ok_or_else(|| {
AssetError::CitationOutsideSource {
locator: citation.locator.clone(),
}
})?;
let bytes = canonical_excerpt_bytes(&excerpt);
let extension = normalized_extension(&excerpt.path, true);
let object = write_object(workspace_root, deck_id, &bytes, &extension)?;
let asset = file_name(&object)?;
if evidence_seen.insert(asset.clone()) {
evidence.push(asset.clone());
}
citations.push(AtRewrite {
at: excerpt_provenance(&excerpt, &inputs[input_index]),
fingerprint: Some(crate::source::excerpt_fingerprint(&excerpt)),
asset: Some(asset),
line: citation.line,
});
}
}
let mut image_rewrites = Vec::new();
let mut image_objects = HashSet::new();
let boundaries = source_boundaries(workspace_root, &inputs);
for image in crate::parser::image_references(&text) {
if crate::deck::is_url(&image.source) {
continue;
}
let source = resolve_image_source(workspace_root, &image.source)?;
if !boundaries
.iter()
.any(|boundary| source.starts_with(boundary))
{
return Err(AssetError::ImageOutsideBoundary(source));
}
if !source.is_file() {
return Err(AssetError::MissingImage(source));
}
let bytes = read_bytes(&source)?;
let extension = normalized_extension(&source, false);
let object = write_object(workspace_root, deck_id, &bytes, &extension)?;
let name = file_name(&object)?;
image_objects.insert(name.clone());
image_rewrites.push((image.destination, format!("{ROOT}/{deck_id}/{name}")));
}
let rewritten = crate::deck::rewrite_frozen_assets(
&text,
parsed.frontmatter_span,
None,
&citations,
&image_rewrites,
)
.map_err(|source| AssetError::Deck {
path: deck.path.clone(),
source,
})?;
let parse = |stage: &str, text: &str| {
crate::parser::parse(&deck.subject, text).map_err(|source| AssetError::Deck {
path: deck.path.with_extension(format!("md ({stage})")),
source: DeckError::Parse {
path: deck.path.clone(),
source,
},
})
};
let verified = parse("frozen", &rewritten)?;
if verified.deck_token.as_deref() != Some(deck_id) {
return Err(AssetError::MissingDeckId(deck.path.clone()));
}
let diagram = freeze_diagrams(
workspace_root,
deck_id,
&rewritten,
verified.frontmatter_span,
diagram_command,
)?;
let rewritten = if diagram.replacements.is_empty() {
rewritten
} else {
let stamped = apply_stamp_edits(&rewritten, &diagram.replacements).map_err(|error| {
AssetError::Io {
path: deck.path.clone(),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, error),
}
})?;
parse("diagram-stamped", &stamped)?;
stamped
};
crate::deck::write_deck_text(&deck.path, &rewritten).map_err(|source| AssetError::Deck {
path: deck.path.clone(),
source,
})?;
Ok(FreezeReport {
evidence: evidence.len(),
images: image_objects.len(),
diagrams: diagram.frozen,
diagram_warnings: diagram.warnings,
})
}
fn apply_stamp_edits(
text: &str,
edits: &[(std::ops::Range<usize>, String)],
) -> Result<String, String> {
let mut edits = edits.to_vec();
edits.sort_by_key(|(range, _)| range.start);
let mut previous_end = 0;
for (range, _) in &edits {
if range.start < previous_end
|| range.start > range.end
|| range.end > text.len()
|| !text.is_char_boundary(range.start)
|| !text.is_char_boundary(range.end)
{
return Err(format!(
"invalid stamp edit at {}..{}",
range.start, range.end
));
}
previous_end = range.end.max(range.start + 1);
}
let mut out = text.to_string();
for (range, replacement) in edits.into_iter().rev() {
out.replace_range(range, &replacement);
}
Ok(out)
}
#[cfg(feature = "full")]
pub fn repair_diagrams(path: &Path) -> Result<(usize, FreezeReport), AssetError> {
repair_diagrams_with(path, default_diagram_command())
}
#[cfg(feature = "full")]
pub(crate) fn repair_diagrams_with(
path: &Path,
diagram_command: Option<&str>,
) -> Result<(usize, FreezeReport), AssetError> {
crate::workspace::root_for_deck(path)
.ok_or_else(|| AssetError::NotWorkspaceMember(path.to_path_buf()))?;
let subject = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| AssetError::InvalidName(path.display().to_string()))?;
let text = read_text(path)?;
let parsed = crate::parser::parse(subject, &text).map_err(|source| AssetError::Deck {
path: path.to_path_buf(),
source: DeckError::Parse {
path: path.to_path_buf(),
source,
},
})?;
if parsed.deck_token.is_none() {
return Err(AssetError::MissingDeckId(path.to_path_buf()));
}
let found = crate::diagram::fences_in_document(&text, parsed.frontmatter_span);
let attached: HashSet<usize> = found
.fences
.iter()
.filter_map(|fence| fence.stamp.as_ref())
.map(|(range, _)| line_of_offset(&text, range.start))
.collect();
let orphan_lines: HashSet<usize> = parsed
.cards
.iter()
.flat_map(|card| &card.diagrams)
.filter(|stamp| !attached.contains(&stamp.line))
.map(|stamp| stamp.line)
.collect();
if !orphan_lines.is_empty() {
let mut kept = String::with_capacity(text.len());
for (index, line) in text.split_inclusive('\n').enumerate() {
if !orphan_lines.contains(&(index + 1)) {
kept.push_str(line);
}
}
crate::deck::write_deck_text(path, &kept).map_err(|source| AssetError::Deck {
path: path.to_path_buf(),
source,
})?;
}
let needs_freeze = found.fences.iter().any(|fence| {
fence
.stamp
.as_ref()
.is_none_or(|(_, print)| *print != crate::diagram::fingerprint(&fence.source))
});
let report = if needs_freeze {
freeze_member_with(path, diagram_command)?
} else {
FreezeReport {
evidence: 0,
images: 0,
diagrams: 0,
diagram_warnings: Vec::new(),
}
};
Ok((orphan_lines.len(), report))
}
#[cfg(feature = "full")]
fn line_of_offset(text: &str, offset: usize) -> usize {
text[..offset].bytes().filter(|byte| *byte == b'\n').count() + 1
}
#[derive(Default)]
struct DiagramOutcome {
replacements: Vec<(std::ops::Range<usize>, String)>,
warnings: Vec<String>,
frozen: usize,
}
#[cfg(not(feature = "full"))]
fn freeze_diagrams(
_workspace_root: &Path,
_deck_id: &str,
_text: &str,
_frontmatter: Option<(usize, usize)>,
_command: Option<&str>,
) -> Result<DiagramOutcome, AssetError> {
Ok(DiagramOutcome::default())
}
#[cfg(feature = "full")]
fn freeze_diagrams(
workspace_root: &Path,
deck_id: &str,
text: &str,
frontmatter: Option<(usize, usize)>,
command: Option<&str>,
) -> Result<DiagramOutcome, AssetError> {
use crate::diagram;
let mut outcome = DiagramOutcome::default();
let Some(command) = command else {
return Ok(outcome);
};
let found = diagram::fences_in_document(text, frontmatter);
if found.unclosed {
outcome
.warnings
.push("an unclosed mermaid fence cannot be frozen".to_string());
}
let stale: Vec<&diagram::RawFence> = found
.fences
.iter()
.filter(|fence| {
fence
.stamp
.as_ref()
.is_none_or(|(_, fingerprint)| *fingerprint != diagram::fingerprint(&fence.source))
})
.collect();
if stale.is_empty() {
return Ok(outcome);
}
let family = match diagram::available_family() {
Ok(family) => family,
Err(error) => {
outcome
.warnings
.push(format!("diagrams not frozen: {error:#}"));
return Ok(outcome);
}
};
let sources: Vec<String> = stale.iter().map(|fence| fence.source.clone()).collect();
let rendered =
match diagram::render_batch(command, Some(family), &sources, diagram::RENDER_TIMEOUT) {
Ok(rendered) => rendered,
Err(error) => {
outcome.warnings.push(format!(
"diagrams not frozen: {error:#}; {}",
diagram::REMEDY
));
return Ok(outcome);
}
};
struct PendingFreeze<'a> {
fence: &'a diagram::RawFence,
fingerprint: String,
svg: String,
original: Option<diagram::Geometry>,
marker: String,
probes: Vec<diagram::SourceProbe>,
}
let mut pending: Vec<PendingFreeze> = Vec::new();
for (fence, rendered) in stale.iter().zip(rendered) {
let fingerprint = diagram::fingerprint(&fence.source);
let svg = match rendered {
Ok(svg) => svg,
Err(message) => {
outcome
.warnings
.push(format!("diagram {fingerprint} did not render: {message}"));
continue;
}
};
let original = diagram::geometry(&svg).ok();
let (marker, probes, truncated) = match &original {
Some(found) => diagram::probe_plan(&fence.source, &found.labels),
None => (String::new(), Vec::new(), false),
};
if truncated {
outcome.warnings.push(format!(
"diagram {fingerprint}: more label-text occurrences than the probe budget; unprobed labels stay unbindable"
));
}
pending.push(PendingFreeze {
fence,
fingerprint,
svg,
original,
marker,
probes,
});
}
let probe_sources: Vec<String> = pending
.iter()
.flat_map(|p| p.probes.iter().map(|probe| probe.source.clone()))
.collect();
let probe_results = if probe_sources.is_empty() {
Vec::new()
} else {
match diagram::render_batch(
command,
Some(family),
&probe_sources,
diagram::RENDER_TIMEOUT,
) {
Ok(results) => results,
Err(error) => {
outcome.warnings.push(format!(
"diagram label sources not probed (labels stay unbindable): {error:#}"
));
Vec::new()
}
}
};
struct ProbedFreeze<'a> {
p: PendingFreeze<'a>,
assigned: Vec<(String, usize, usize)>,
retries: Vec<diagram::SourceProbe>,
failure: Option<String>,
}
let mut probe_results = probe_results.into_iter();
let mut probed: Vec<ProbedFreeze> = Vec::new();
for p in pending {
let mut assigned: Vec<(String, usize, usize)> = Vec::new();
let mut retries: Vec<diagram::SourceProbe> = Vec::new();
let results: Vec<Result<String, String>> =
probe_results.by_ref().take(p.probes.len()).collect();
let failure = assign_share(
&p.probes,
&results,
p.original.as_ref(),
&p.marker,
&mut assigned,
|probe, variant| {
let occurrence = &p.fence.source[probe.start..probe.end];
let original = p.original.as_ref().map(|found| found.labels.as_slice());
if diagram::bare_rename(occurrence, original.unwrap_or(&[]), &p.marker, variant) {
retries.push(diagram::bracket_probe(&p.fence.source, probe, &p.marker));
}
},
);
if failure.is_some() {
retries.clear();
}
let budget = diagram::PROBE_BUDGET.saturating_sub(p.probes.len());
if retries.len() > budget {
outcome.warnings.push(format!(
"diagram {}: bare-node retries over the probe budget; unretried labels stay unbindable",
p.fingerprint
));
retries.truncate(budget);
}
probed.push(ProbedFreeze {
p,
assigned,
retries,
failure,
});
}
let retry_sources: Vec<String> = probed
.iter()
.flat_map(|f| f.retries.iter().map(|probe| probe.source.clone()))
.collect();
let retry_results = if retry_sources.is_empty() {
Vec::new()
} else {
match diagram::render_batch(
command,
Some(family),
&retry_sources,
diagram::RENDER_TIMEOUT,
) {
Ok(results) => results,
Err(error) => {
outcome.warnings.push(format!(
"diagram bare-node retries not probed (their labels stay unbindable): {error:#}"
));
Vec::new()
}
}
};
let mut retry_results = retry_results.into_iter();
for f in probed {
let fence = f.p.fence;
let fingerprint = f.p.fingerprint;
let mut assigned = f.assigned;
let results: Vec<Result<String, String>> =
retry_results.by_ref().take(f.retries.len()).collect();
let probe_failure = f.failure.or_else(|| {
assign_share(
&f.retries,
&results,
f.p.original.as_ref(),
&f.p.marker,
&mut assigned,
|_, _| {},
)
});
if let Some(reason) = probe_failure {
outcome.warnings.push(format!(
"diagram {fingerprint} not frozen: {reason}; re-run `alix deck init` when the renderer is healthy"
));
continue;
}
let sources = diagram::collate_assignments(&assigned);
let frozen = match diagram::freeze_fence(&f.p.svg, family, &sources) {
Ok(frozen) => frozen,
Err(error) => {
outcome
.warnings
.push(format!("diagram {fingerprint} not frozen: {error:#}"));
continue;
}
};
let geometry_bytes =
serde_json::to_vec(&frozen.geometry).map_err(|source| AssetError::Io {
path: workspace_root.to_path_buf(),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, source),
})?;
write_object(workspace_root, deck_id, &frozen.image, "png")?;
let geometry_object = write_object(workspace_root, deck_id, &geometry_bytes, "json")?;
let stamp = format!(
"<!-- diagram: fingerprint: {fingerprint} asset: {} geometry: {} -->",
frozen.geometry.image,
file_name(&geometry_object)?,
);
match &fence.stamp {
Some((range, _)) => outcome.replacements.push((range.clone(), stamp)),
None => {
let after_newline = fence.insert_at == 0
|| text.as_bytes().get(fence.insert_at - 1) == Some(&b'\n');
let crlf = if after_newline {
fence.insert_at >= 2
&& &text.as_bytes()[fence.insert_at - 2..fence.insert_at] == b"\r\n"
} else {
text.contains("\r\n")
};
let ending = if crlf { "\r\n" } else { "\n" };
let line = if after_newline {
format!("{stamp}{ending}")
} else {
format!("{ending}{stamp}{ending}")
};
outcome
.replacements
.push((fence.insert_at..fence.insert_at, line));
}
}
outcome.frozen += 1;
}
Ok(outcome)
}
#[cfg(feature = "full")]
fn assign_share(
probes: &[crate::diagram::SourceProbe],
results: &[Result<String, String>],
original: Option<&crate::diagram::Geometry>,
marker: &str,
assigned: &mut Vec<(String, usize, usize)>,
mut on_rejected: impl FnMut(&crate::diagram::SourceProbe, &[crate::diagram::Label]),
) -> Option<String> {
if results.len() < probes.len() {
return Some("the probe batch returned too few renders".to_string());
}
for (probe, result) in probes.iter().zip(results) {
match (result, original) {
(Ok(variant_svg), Some(original)) => match crate::diagram::geometry(variant_svg) {
Ok(variant) => {
if let Some(id) =
crate::diagram::probe_assignment(&original.labels, marker, &variant.labels)
{
assigned.push((id, probe.start, probe.end));
} else {
on_rejected(probe, &variant.labels);
}
}
Err(error) => return Some(format!("a probe render was unreadable: {error:#}")),
},
(Err(message), _) if message == crate::diagram::NO_RENDER_OUTPUT => {
return Some("a probe render produced no output".to_string());
}
(Err(_), _) | (Ok(_), None) => {}
}
}
None
}
fn declared_source_inputs(
workspace_root: &Path,
deck: &Deck,
) -> Result<Vec<SourceInput>, AssetError> {
let mut inputs = Vec::new();
let layers = deck.source_layers();
for source in layers.base_locals() {
if let Some(path) = crate::source::source_path(source, Some(workspace_root)) {
let path = path
.canonicalize()
.map_err(|_| AssetError::MissingSource(path.clone()))?;
if path.is_file() {
let bytes = read_bytes(&path)?;
std::str::from_utf8(&bytes).map_err(|_| AssetError::NonTextSource(path.clone()))?;
inputs.push(SourceInput::File { path });
} else if path.is_dir() {
inputs.push(SourceInput::Directory { path });
} else {
return Err(AssetError::MissingSource(path));
}
}
}
Ok(inputs)
}
fn citation_excerpt(
source_base: &SourceBase,
citation: &crate::card::SourceCitation,
) -> Result<Excerpt, AssetError> {
match source_base
.inspect_citation(citation)
.map_err(|error| AssetError::Citation {
locator: citation.locator.clone(),
message: format!("{error:#}"),
})? {
CitationIntegrity::Current(excerpt)
| CitationIntegrity::Unfingerprinted { excerpt, .. } => Ok(excerpt),
CitationIntegrity::Relocated { locator, .. } => Err(AssetError::Citation {
locator: citation.locator.clone(),
message: format!("the excerpt moved to `{locator}`"),
}),
CitationIntegrity::Changed => Err(AssetError::Citation {
locator: citation.locator.clone(),
message: "the excerpt changed or disappeared".to_string(),
}),
CitationIntegrity::Ambiguous { locators } => Err(AssetError::Citation {
locator: citation.locator.clone(),
message: format!(
"the excerpt matches several ranges: {}",
locators.join(", ")
),
}),
}
}
fn source_owner(inputs: &[SourceInput], excerpt: &Excerpt) -> Option<usize> {
let path = excerpt.path.canonicalize().ok()?;
inputs.iter().position(|input| match input {
SourceInput::File { path: source, .. } => path == *source,
SourceInput::Directory { path: source, .. } => path.starts_with(source),
})
}
fn canonical_excerpt_bytes(excerpt: &Excerpt) -> Vec<u8> {
let mut text = excerpt
.lines
.iter()
.map(|(_, line)| line.as_str())
.collect::<Vec<_>>()
.join("\n");
text.push('\n');
text.into_bytes()
}
fn excerpt_provenance(excerpt: &Excerpt, input: &SourceInput) -> String {
let first = excerpt.lines.first().map(|line| line.0).unwrap_or(1);
let last = excerpt.lines.last().map(|line| line.0).unwrap_or(first);
let path = match input {
SourceInput::File { path, .. } => path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string()),
SourceInput::Directory { path, .. } => excerpt
.path
.canonicalize()
.unwrap_or_else(|_| excerpt.path.clone())
.strip_prefix(path)
.unwrap_or(&excerpt.path)
.to_string_lossy()
.into_owned(),
};
if first == last {
format!("{path}:{first}")
} else {
format!("{path}:{first}-{last}")
}
}
fn source_boundaries(workspace_root: &Path, inputs: &[SourceInput]) -> Vec<PathBuf> {
let mut boundaries = vec![
workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf()),
];
for input in inputs {
let boundary = match input {
SourceInput::File { path, .. } => path.parent().unwrap_or(path),
SourceInput::Directory { path, .. } => path,
};
if !boundaries.contains(&boundary.to_path_buf()) {
boundaries.push(boundary.to_path_buf());
}
}
boundaries
}
fn resolve_image_source(workspace_root: &Path, source: &str) -> Result<PathBuf, AssetError> {
let path = Path::new(source);
let path = if path.is_absolute() {
path.to_path_buf()
} else {
workspace_root.join(path)
};
path.canonicalize()
.map_err(|_| AssetError::MissingImage(path))
}
pub fn resolve_diagrams(
deck_path: &Path,
deck_token: Option<&str>,
cards: &mut [crate::card::Card],
stamp_freshness: &std::collections::HashMap<usize, bool>,
) -> Vec<String> {
let Some(root) = crate::workspace::root_for_deck(deck_path) else {
return Vec::new();
};
let Some(deck_id) = deck_token else {
return Vec::new();
};
let Ok(owned) = deck_dir(&root, deck_id) else {
return Vec::new();
};
let mut unresolved: std::collections::BTreeSet<String> = Default::default();
for card in cards.iter_mut() {
for stamp in &card.diagrams {
if stamp_freshness.get(&stamp.line) != Some(&true) {
unresolved.insert(stamp.fingerprint.clone());
continue;
}
let png = owned.join(&stamp.asset);
if !png.is_file() {
unresolved.insert(stamp.fingerprint.clone());
continue;
}
let Ok(bytes) = std::fs::read(owned.join(&stamp.geometry)) else {
unresolved.insert(stamp.fingerprint.clone());
continue;
};
let Ok(geometry) = serde_json::from_slice::<crate::diagram::DiagramGeometry>(&bytes)
else {
unresolved.insert(stamp.fingerprint.clone());
continue;
};
if geometry.image != stamp.asset {
unresolved.insert(stamp.fingerprint.clone());
continue;
}
card.resolved_diagrams.push(crate::card::ResolvedDiagram {
fingerprint: stamp.fingerprint.clone(),
png,
geometry,
});
}
}
let mut invalid: std::collections::BTreeSet<String> = Default::default();
for card in cards.iter() {
for fence in &card.answer_fences {
for resolved in &card.resolved_diagrams {
if resolved.fingerprint == fence.fingerprint
&& crate::diagram::validate_label_sources(&resolved.geometry, &fence.interior)
.is_err()
{
invalid.insert(fence.fingerprint.clone());
}
}
}
}
if !invalid.is_empty() {
for card in cards.iter_mut() {
card.resolved_diagrams
.retain(|resolved| !invalid.contains(&resolved.fingerprint));
}
unresolved.extend(invalid);
}
if unresolved.is_empty() {
Vec::new()
} else {
vec![format!(
"{} frozen diagram(s) did not resolve and fall back to source; run `alix doctor` for details",
unresolved.len()
)]
}
}
pub fn validate_member(deck: &Deck) -> Result<(), AssetError> {
let workspace_root = crate::workspace::root_for_deck(&deck.path)
.ok_or_else(|| AssetError::NotWorkspaceMember(deck.path.clone()))?;
validate_at_root(deck, &workspace_root)
}
pub fn validate_at_root(deck: &Deck, root: &Path) -> Result<(), AssetError> {
let deck_id = deck
.deck_token
.as_deref()
.ok_or_else(|| AssetError::MissingDeckId(deck.path.clone()))?;
let owned = deck_dir(root, deck_id)?;
for card in &deck.cards {
for citation in &card.citations {
if let Some(asset) = citation.asset.as_deref() {
let path = owned.join(asset);
if !path.is_file() {
return Err(AssetError::MissingAsset(path));
}
}
}
for stamp in &card.diagrams {
for name in [&stamp.asset, &stamp.geometry] {
let path = owned.join(name);
if !path.is_file() {
return Err(AssetError::MissingAsset(path));
}
}
}
}
if owned.is_dir() {
validate_owned_dir(root, deck_id)?;
}
Ok(())
}
pub fn validate_owned_dir(root: &Path, deck_id: &str) -> Result<(), AssetError> {
let owned = deck_dir(root, deck_id)?;
for entry in std::fs::read_dir(&owned).map_err(|source| AssetError::Io {
path: owned.clone(),
source,
})? {
let entry = entry.map_err(|source| AssetError::Io {
path: owned.clone(),
source,
})?;
let path = entry.path();
if !path.is_file() {
return Err(AssetError::InvalidName(path.display().to_string()));
}
verify_object(&path)?;
}
Ok(())
}
pub fn validate_image(deck: &Deck, source: &str) -> Result<(), AssetError> {
let workspace_root = crate::workspace::root_for_deck(&deck.path)
.ok_or_else(|| AssetError::NotWorkspaceMember(deck.path.clone()))?;
validate_image_at_root(deck, &workspace_root, source)
}
pub fn validate_image_at_root(deck: &Deck, root: &Path, source: &str) -> Result<(), AssetError> {
let deck_id = deck
.deck_token
.as_deref()
.ok_or_else(|| AssetError::MissingDeckId(deck.path.clone()))?;
let path = resolve_image_source(root, source)?;
let owned = deck_dir(root, deck_id)?;
let canonical_owned = owned.canonicalize().map_err(|source| AssetError::Io {
path: owned,
source,
})?;
if !path.starts_with(&canonical_owned) {
return Err(AssetError::ImageOutsideBoundary(path));
}
verify_object(&path)
}
fn read_text(path: &Path) -> Result<String, AssetError> {
std::fs::read_to_string(path).map_err(|source| AssetError::Io {
path: path.to_path_buf(),
source,
})
}
fn read_bytes(path: &Path) -> Result<Vec<u8>, AssetError> {
std::fs::read(path).map_err(|source| AssetError::Io {
path: path.to_path_buf(),
source,
})
}
fn file_name(path: &Path) -> Result<String, AssetError> {
path.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
.ok_or_else(|| AssetError::InvalidName(path.display().to_string()))
}
pub fn verify_object(path: &Path) -> Result<(), AssetError> {
let name = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| AssetError::InvalidName(path.display().to_string()))?;
let extension = path
.extension()
.and_then(|value| value.to_str())
.ok_or_else(|| AssetError::InvalidName(name.to_string()))?;
let stem = name
.strip_suffix(&format!(".{extension}"))
.and_then(|value| value.strip_prefix(DIGEST_PREFIX))
.filter(|value| value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit()))
.ok_or_else(|| AssetError::InvalidName(name.to_string()))?;
let bytes = std::fs::read(path).map_err(|source| AssetError::Io {
path: path.to_path_buf(),
source,
})?;
let expected = object_name(&bytes, extension);
if expected == name && stem.chars().all(|c| !c.is_ascii_uppercase()) {
Ok(())
} else {
Err(AssetError::DigestMismatch {
path: path.to_path_buf(),
})
}
}
pub fn is_object_name(name: &str) -> bool {
let Some((stem, extension)) = name.rsplit_once('.') else {
return false;
};
let Some(digest) = stem.strip_prefix(DIGEST_PREFIX) else {
return false;
};
digest.len() == 64
&& digest
.chars()
.all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase())
&& !extension.is_empty()
&& extension
.chars()
.all(|character| character.is_ascii_alphanumeric() && !character.is_ascii_uppercase())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn workspace() -> tempfile::TempDir {
let directory = tempfile::tempdir().unwrap();
std::fs::create_dir(directory.path().join("decks")).unwrap();
std::fs::write(directory.path().join("alix.toml"), "").unwrap();
directory
}
#[test]
fn initializing_a_personal_file_refuses_and_leaves_it_untouched() {
let dir = workspace();
let decks = dir.path().join("decks");
std::fs::write(decks.join("spanish.md"), "## darse cuenta\nto realise\n").unwrap();
let personal = decks.join("spanish.personal.md");
let text = "---\nformat-version: 1\nfor: deck-abc\n---\n\n\
<!-- note: card-one -->\n> mine\n";
std::fs::write(&personal, text).unwrap();
let error = initialize(&personal).unwrap_err();
assert!(
matches!(&error, InitializeError::Personal { path } if path == &personal),
"{error:?}"
);
assert_eq!(
text,
std::fs::read_to_string(&personal).unwrap(),
"a refusal writes nothing: no `id:`, no card stamps"
);
}
#[test]
fn object_names_are_exact_byte_sha256_addresses() {
assert_eq!(
"sha256-ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.rs",
object_name(b"abc", "rs")
);
assert_ne!(object_name(b"abc", "rs"), object_name(b"abc\n", "rs"));
}
#[test]
fn extensions_are_normalized_and_untrusted_values_fall_back() {
assert_eq!("png", normalized_extension(Path::new("diagram.PNG"), false));
assert_eq!(
"txt",
normalized_extension(Path::new("source.weird-name"), true)
);
assert_eq!("bin", normalized_extension(Path::new("image"), false));
}
#[test]
fn identical_objects_in_one_deck_reuse_the_same_path() {
let directory = tempfile::tempdir().unwrap();
let first = write_object(directory.path(), "deck-deck1", b"same", "txt").unwrap();
let second = write_object(directory.path(), "deck-deck1", b"same", "txt").unwrap();
assert_eq!(first, second);
assert_eq!(b"same", std::fs::read(first).unwrap().as_slice());
}
#[test]
fn identical_objects_in_two_decks_have_distinct_owned_paths() {
let directory = tempfile::tempdir().unwrap();
let first = write_object(directory.path(), "deck-deck1", b"same", "txt").unwrap();
let second = write_object(directory.path(), "deck-deck2", b"same", "txt").unwrap();
assert_ne!(first, second);
assert_eq!(first.file_name(), second.file_name());
}
#[test]
fn an_existing_corrupt_object_is_rejected_instead_of_reused() {
let directory = tempfile::tempdir().unwrap();
let path = write_object(directory.path(), "deck-deck1", b"original", "txt").unwrap();
std::fs::write(&path, "changed").unwrap();
let error = write_object(directory.path(), "deck-deck1", b"original", "txt").unwrap_err();
assert!(matches!(error, AssetError::DigestMismatch { .. }));
}
#[test]
fn object_validation_rejects_noncanonical_names() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("human-name.png");
std::fs::write(&path, "bytes").unwrap();
assert!(matches!(
verify_object(&path),
Err(AssetError::InvalidName(_))
));
assert!(!is_object_name("human-name.png"));
let bad_digest = format!("{DIGEST_PREFIX}{}.png", "g".repeat(64));
let path = directory.path().join(&bad_digest);
std::fs::write(&path, "bytes").unwrap();
assert!(matches!(
verify_object(&path),
Err(AssetError::InvalidName(name)) if name == bad_digest
));
let valid_digest = "a".repeat(64);
for invalid in [
format!("{DIGEST_PREFIX}{}.png", "a".repeat(63)),
format!("{DIGEST_PREFIX}{}.png", "g".repeat(64)),
format!("{DIGEST_PREFIX}{}.png", "A".repeat(64)),
format!("{DIGEST_PREFIX}{valid_digest}."),
format!("{DIGEST_PREFIX}{valid_digest}.p-ng"),
format!("{DIGEST_PREFIX}{valid_digest}.PNG"),
] {
assert!(!is_object_name(&invalid), "accepted {invalid}");
}
}
#[test]
fn image_validation_accepts_only_objects_owned_by_the_deck() {
let workspace = workspace();
let deck_path = workspace.path().join("decks/facts.md");
std::fs::write(
&deck_path,
"---\nformat-version: 1\nid: \"deck-deck1\"\n---\n## q\na\n",
)
.unwrap();
let deck = Deck::load(&deck_path).unwrap();
let inside = write_object(workspace.path(), "deck-deck1", b"inside", "png").unwrap();
assert!(validate_image_at_root(&deck, workspace.path(), inside.to_str().unwrap()).is_ok());
let outside = tempfile::tempdir().unwrap();
let name = object_name(b"outside", "png");
let outside_path = outside.path().join(name);
std::fs::write(&outside_path, b"outside").unwrap();
let resolved = outside_path.canonicalize().unwrap();
assert!(matches!(
validate_image_at_root(&deck, workspace.path(), outside_path.to_str().unwrap()),
Err(AssetError::ImageOutsideBoundary(path)) if path == resolved
));
}
#[test]
fn freezing_a_file_source_freezes_the_cited_excerpt_and_ingests_images() {
let directory = workspace();
let source = directory.path().join("notes.md");
std::fs::write(&source, "one \r\ntwo\r\nthree\r\n").unwrap();
std::fs::write(directory.path().join("diagram.PNG"), [0, 1, 2, 255]).unwrap();
std::fs::write(directory.path().join("old image.png"), [9, 9]).unwrap();
let path = directory.path().join("decks/facts.md");
let text = format!(
"---\nformat-version: 1\nid: \"deck-deck1\"\nsource: {}\n---\n## q\n\n\na\n<!-- at: notes.md:2 -->\n",
crate::parser::yaml_quote(&source.display().to_string())
);
std::fs::write(&path, &text).unwrap();
let report = freeze_member(&path).unwrap();
let frozen = std::fs::read_to_string(&path).unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(
FreezeReport {
evidence: 1,
images: 2,
diagrams: 0,
diagram_warnings: Vec::new(),
},
report
);
assert!(deck.is_frozen());
assert_eq!(
vec![source.display().to_string()],
deck.sources,
"the live source declaration stays untouched"
);
assert!(
!frozen.contains("origin:"),
"freezing stamps nothing: {frozen}"
);
let image_name = object_name(&[0, 1, 2, 255], "png");
assert!(frozen.contains(&format!("](assets/deck-deck1/{image_name})")));
let titled_name = object_name(&[9, 9], "png");
assert!(
frozen.contains(&format!(
""
)),
"the rewrite replaces only the destination span, keeping the \
title: {frozen}"
);
assert_eq!(
[0, 1, 2, 255],
std::fs::read(
directory
.path()
.join(format!("assets/deck-deck1/{image_name}"))
)
.unwrap()
.as_slice()
);
let whole_name = object_name(b"one \r\ntwo\r\nthree\r\n", "md");
assert!(
!directory
.path()
.join(format!("assets/deck-deck1/{whole_name}"))
.exists(),
"no whole-file object is written"
);
let excerpt_name = object_name(b"two\n", "md");
assert!(
frozen.contains("<!-- at: notes.md:2 fingerprint: xxh64-")
&& frozen.contains(&format!(" asset: {excerpt_name} -->")),
"{frozen}"
);
assert_eq!(
b"two\n",
std::fs::read(
directory
.path()
.join(format!("assets/deck-deck1/{excerpt_name}"))
)
.unwrap()
.as_slice()
);
}
#[test]
fn a_frozen_file_source_citation_reads_asset_bytes_after_the_live_file_mutates() {
let directory = workspace();
let source = directory.path().join("notes.md");
std::fs::write(&source, "one\ntwo\nthree\n").unwrap();
let path = directory.path().join("decks/facts.md");
std::fs::write(
&path,
format!(
"---\nformat-version: 1\nid: \"deck-deck5\"\nsource: {}\n---\n## q\na\n<!-- at: notes.md:2 -->\n",
crate::parser::yaml_quote(&source.display().to_string())
),
)
.unwrap();
freeze_member(&path).unwrap();
std::fs::write(&source, "MUTATED\nLIVE\nFILE\n").unwrap();
let deck = Deck::load(&path).unwrap();
let citation = &deck.cards[0].citations[0];
assert_eq!("notes.md:2", citation.locator);
assert!(citation.asset.is_some());
let CitationIntegrity::Current(excerpt) = SourceBase::for_deck(&deck)
.inspect_citation(citation)
.unwrap()
else {
panic!("the frozen citation must stay Current against the asset");
};
assert_eq!(vec![(2, "two".to_string())], excerpt.lines);
}
#[test]
fn freezing_a_directory_stores_only_the_cited_lines_of_cited_files() {
let directory = workspace();
let source = directory.path().join("source");
std::fs::create_dir(&source).unwrap();
std::fs::write(source.join("lib.rs"), "a \nb\nc\n").unwrap();
std::fs::write(source.join("uncited.rs"), "secret\n").unwrap();
let path = directory.path().join("decks/trace.md");
std::fs::write(
&path,
format!(
"---\nformat-version: 1\nid: \"deck-deck2\"\nsource: {}\n---\n## q\np\n<!-- at: lib.rs:2-3 -->\n",
crate::parser::yaml_quote(&source.display().to_string())
),
)
.unwrap();
let report = freeze_member(&path).unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(
FreezeReport {
evidence: 1,
images: 0,
diagrams: 0,
diagram_warnings: Vec::new(),
},
report
);
assert_eq!(
vec![source.display().to_string()],
deck.sources,
"the live source declaration stays untouched"
);
let evidence_name = object_name(b"b\nc\n", "rs");
let object = directory
.path()
.join(format!("assets/deck-deck2/{evidence_name}"));
assert_eq!(b"b\nc\n", std::fs::read(&object).unwrap().as_slice());
let frozen_asset = std::fs::read_to_string(&object).unwrap();
assert!(!frozen_asset.contains("a "));
let frozen = std::fs::read_to_string(&path).unwrap();
assert!(!frozen.contains("uncited.rs"));
assert!(!frozen.contains("secret"));
assert!(
frozen.contains("<!-- at: lib.rs:2-3 fingerprint: xxh64-")
&& frozen.contains(&format!(" asset: {evidence_name} -->")),
"{frozen}"
);
assert_eq!(
1,
std::fs::read_dir(directory.path().join("assets/deck-deck2"))
.unwrap()
.count()
);
}
#[test]
fn a_frozen_directory_source_citation_reads_asset_bytes_after_the_live_file_mutates() {
let directory = workspace();
let source = directory.path().join("source");
std::fs::create_dir(&source).unwrap();
std::fs::write(source.join("lib.rs"), "a\nb\nc\n").unwrap();
let path = directory.path().join("decks/trace.md");
std::fs::write(
&path,
format!(
"---\nformat-version: 1\nid: \"deck-deck6\"\nsource: {}\n---\n## q\np\n<!-- at: lib.rs:2-3 -->\n",
crate::parser::yaml_quote(&source.display().to_string())
),
)
.unwrap();
freeze_member(&path).unwrap();
std::fs::write(source.join("lib.rs"), "REWRITTEN\n").unwrap();
let deck = Deck::load(&path).unwrap();
let citation = &deck.cards[0].citations[0];
assert_eq!("lib.rs:2-3", citation.locator);
let CitationIntegrity::Current(excerpt) = SourceBase::for_deck(&deck)
.inspect_citation(citation)
.unwrap()
else {
panic!("the frozen citation must stay Current against the asset");
};
assert_eq!(
vec![(2, "b".to_string()), (3, "c".to_string())],
excerpt.lines
);
}
#[test]
fn freezing_a_citation_beyond_the_display_cap_keeps_every_line_and_the_authored_range() {
let directory = workspace();
let source = directory.path().join("notes.md");
let body: String = (1..=120).map(|line| format!("line {line}\n")).collect();
std::fs::write(&source, &body).unwrap();
let path = directory.path().join("decks/big.md");
std::fs::write(
&path,
format!(
"---\nformat-version: 1\nid: \"deck-deck8\"\nsource: {}\n---\n## q\na\n<!-- at: notes.md:5-104 -->\n",
crate::parser::yaml_quote(&source.display().to_string())
),
)
.unwrap();
freeze_member(&path).unwrap();
let cited: String = (5..=104).map(|line| format!("line {line}\n")).collect();
let evidence_name = object_name(cited.as_bytes(), "md");
assert_eq!(
cited.as_bytes(),
std::fs::read(
directory
.path()
.join(format!("assets/deck-deck8/{evidence_name}"))
)
.unwrap()
.as_slice()
);
let frozen = std::fs::read_to_string(&path).unwrap();
assert!(
frozen.contains("<!-- at: notes.md:5-104 fingerprint: xxh64-")
&& frozen.contains(&format!(" asset: {evidence_name} -->")),
"the authored range must survive freezing verbatim: {frozen}"
);
std::fs::write(&source, "MUTATED\n").unwrap();
let deck = Deck::load(&path).unwrap();
let CitationIntegrity::Current(excerpt) = SourceBase::for_deck(&deck)
.inspect_citation(&deck.cards[0].citations[0])
.unwrap()
else {
panic!("the frozen citation must verify over all 100 lines");
};
assert_eq!(100, excerpt.lines.len());
assert_eq!((5, "line 5".to_string()), excerpt.lines[0]);
assert_eq!((104, "line 104".to_string()), excerpt.lines[99]);
}
#[test]
fn freezing_without_citations_writes_no_assets_and_keeps_the_source_live() {
let directory = workspace();
let source = directory.path().join("source");
std::fs::create_dir(&source).unwrap();
std::fs::write(source.join("lib.rs"), "a\n").unwrap();
let path = directory.path().join("decks/plain.md");
let text = format!(
"---\nformat-version: 1\nid: \"deck-deck3\"\nsource: {}\n---\n## q\na\n",
crate::parser::yaml_quote(&source.display().to_string())
);
std::fs::write(&path, &text).unwrap();
let report = freeze_member(&path).unwrap();
assert_eq!(
FreezeReport {
evidence: 0,
images: 0,
diagrams: 0,
diagram_warnings: Vec::new(),
},
report
);
assert!(!directory.path().join("assets/deck-deck3").exists());
let deck = Deck::load(&path).unwrap();
assert!(!deck.is_frozen());
assert_eq!(
vec![source.display().to_string()],
deck.sources,
"the live source declaration stays untouched"
);
}
#[test]
fn a_member_with_no_own_source_initializes_and_freezes_from_the_workspace_source() {
let directory = workspace();
std::fs::write(
directory.path().join("alix.toml"),
"source = \"notes.md\"\n",
)
.unwrap();
std::fs::write(directory.path().join("notes.md"), "one\ntwo\nthree\n").unwrap();
let path = directory.path().join("decks/facts.md");
std::fs::write(
&path,
"---\nformat-version: 1\nid: \"deck-deck7\"\n---\n## q\na\n<!-- at: notes.md:2 -->\n<!-- id: card-card7 -->\n",
)
.unwrap();
let report = initialize(&path).unwrap();
assert_eq!(
Some(FreezeReport {
evidence: 1,
images: 0,
diagrams: 0,
diagram_warnings: Vec::new(),
}),
report.freeze
);
let excerpt_name = object_name(b"two\n", "md");
assert_eq!(
b"two\n",
std::fs::read(
directory
.path()
.join(format!("assets/deck-deck7/{excerpt_name}"))
)
.unwrap()
.as_slice()
);
let frozen = std::fs::read_to_string(&path).unwrap();
assert!(
frozen.contains("<!-- at: notes.md:2 fingerprint: xxh64-")
&& frozen.contains(&format!(" asset: {excerpt_name} -->")),
"{frozen}"
);
}
#[test]
fn a_missing_image_keeps_the_deck_and_removes_new_assets() {
let directory = workspace();
let source = directory.path().join("notes.md");
std::fs::write(&source, "a\n").unwrap();
let path = directory.path().join("decks/facts.md");
let text = format!(
"---\nformat-version: 1\nid: \"deck-deck4\"\nsource: {}\n---\n## q\n\na\n",
crate::parser::yaml_quote(&source.display().to_string())
);
std::fs::write(&path, &text).unwrap();
let error = freeze_member(&path).unwrap_err();
assert!(matches!(error, AssetError::MissingImage(_)));
assert_eq!(text, std::fs::read_to_string(&path).unwrap());
assert!(!directory.path().join("assets/deck-deck4").exists());
}
#[test]
fn a_failed_freeze_preserves_a_preexisting_empty_assets_root() {
let directory = workspace();
let assets = directory.path().join(ROOT);
std::fs::create_dir(&assets).unwrap();
let path = directory.path().join("decks/facts.md");
std::fs::write(
&path,
"---\nformat-version: 1\nid: \"deck-deck4\"\n---\n## q\n\na\n",
)
.unwrap();
assert!(matches!(
freeze_member(&path),
Err(AssetError::MissingImage(_))
));
assert!(
assets.is_dir(),
"a preexisting assets root must survive cleanup"
);
assert!(!assets.join("deck-deck4").exists());
}
#[test]
fn a_failed_freeze_preserves_a_preexisting_owned_asset_directory() {
let directory = workspace();
let existing = write_object(
directory.path(),
"deck-deck4",
b"owned before this attempt",
"txt",
)
.unwrap();
let path = directory.path().join("decks/facts.md");
std::fs::write(
&path,
"---\nformat-version: 1\nid: \"deck-deck4\"\n---\n## q\n\na\n",
)
.unwrap();
assert!(matches!(
freeze_member(&path),
Err(AssetError::MissingImage(_))
));
assert_eq!(
b"owned before this attempt",
std::fs::read(existing).unwrap().as_slice()
);
}
#[test]
fn source_owner_finds_the_matching_input_instead_of_assuming_the_first() {
let directory = tempfile::tempdir().unwrap();
let first = directory.path().join("first.md");
let second = directory.path().join("second.md");
let outside = directory.path().join("outside.md");
for path in [&first, &second, &outside] {
std::fs::write(path, "line\n").unwrap();
}
let inputs = vec![
SourceInput::File {
path: first.canonicalize().unwrap(),
},
SourceInput::File {
path: second.canonicalize().unwrap(),
},
];
let excerpt = |path: &Path| Excerpt {
path: path.to_path_buf(),
lines: vec![(1, "line".to_string())],
truncated: false,
};
assert_eq!(Some(1), source_owner(&inputs, &excerpt(&second)));
assert_eq!(None, source_owner(&inputs, &excerpt(&outside)));
}
#[test]
fn source_boundaries_are_ordered_and_unique() {
let workspace = workspace();
let root = workspace.path().canonicalize().unwrap();
let source_file = root.join("source.md");
std::fs::write(&source_file, "source").unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_root = outside.path().canonicalize().unwrap();
let inputs = vec![
SourceInput::File {
path: source_file.canonicalize().unwrap(),
},
SourceInput::Directory {
path: outside_root.clone(),
},
SourceInput::Directory {
path: outside_root.clone(),
},
];
assert_eq!(
vec![root, outside_root],
source_boundaries(workspace.path(), &inputs)
);
}
#[test]
fn failed_initialization_restores_the_exact_uninitialized_deck() {
let directory = workspace();
let path = directory.path().join("decks/remote.md");
let original =
b"---\nsource: https://example.test/source.md\n---\n## q\na\n<!-- at: notes.md:1 -->\n";
std::fs::write(&path, original).unwrap();
let error = initialize(&path).unwrap_err();
assert!(matches!(
error,
InitializeError::Freeze(AssetError::Citation { .. })
));
assert_eq!(original, std::fs::read(&path).unwrap().as_slice());
assert_eq!(None, Deck::load(&path).unwrap().deck_token);
assert!(!directory.path().join(ROOT).exists());
}
#[test]
fn a_url_source_blocks_no_initialization_and_contributes_no_frozen_inputs() {
let directory = workspace();
std::fs::write(directory.path().join("notes.md"), "one\ntwo\n").unwrap();
let path = directory.path().join("decks/mixed.md");
std::fs::write(
&path,
"---\nformat-version: 1\nid: \"deck-deck1\"\nsource:\n - https://example.test/page\n - notes.md\n---\n\
## q\na\n<!-- at: notes.md:2 -->\n",
)
.unwrap();
let report = freeze_member(&path).unwrap();
assert_eq!(
FreezeReport {
evidence: 1,
images: 0,
diagrams: 0,
diagram_warnings: Vec::new(),
},
report
);
assert!(Deck::load(&path).unwrap().is_frozen());
let url_only = directory.path().join("decks/url-only.md");
std::fs::write(
&url_only,
"---\nsource: https://example.test/page\n---\n## q\na\n",
)
.unwrap();
let report = initialize(&url_only).unwrap();
assert_eq!(
Some(FreezeReport {
evidence: 0,
images: 0,
diagrams: 0,
diagram_warnings: Vec::new(),
}),
report.freeze
);
}
fn fixture_svg() -> String {
std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/labeled-sekien.svg"),
)
.unwrap()
}
fn fake_sekien(dir: &Path, stdout: &str, stderr: &str) -> std::path::PathBuf {
let out = dir.join("sekien-out");
let err = dir.join("sekien-err");
std::fs::write(&out, stdout).unwrap();
std::fs::write(&err, stderr).unwrap();
crate::testutil::fake_cli(
dir,
&format!(
"cat >/dev/null; cat {}; cat {} >&2; exit 0",
out.display(),
err.display()
),
)
}
fn diagram_deck(directory: &Path, body: &str) -> std::path::PathBuf {
let path = directory.join("decks/graphs.md");
std::fs::write(
&path,
format!("---\nformat-version: 1\nid: \"deck-deck1\"\n---\n{body}"),
)
.unwrap();
path
}
fn variant_svg(marker_on: &str) -> String {
let node = |id: &str, text: &str| {
format!(
r##"<g class="node" id="d1-flowchart-{id}-0"><rect class="label-container" x="0" y="0" width="10" height="10"/><g class="label"><text>{text}</text></g></g>"##
)
};
let edge = |id: &str, text: &str| {
format!(
r##"<g class="edgeLabel"><g class="label" data-id="{id}" transform="translate(0,0)"><rect class="background" x="0" y="0" width="5" height="5"/><text>{text}</text></g></g>"##
)
};
let text_for = |id: &str, base: &str| {
if id == marker_on {
"xq1".to_string()
} else {
base.to_string()
}
};
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">{}{}{}{}{}</svg>"##,
node("A", &text_for("A", "Cache")),
node("B", &text_for("B", "Store")),
node("C", &text_for("C", "Cache")),
edge("L_A_B_0", &text_for("L_A_B_0", "fills")),
edge("L_C_B_0", &text_for("L_C_B_0", "reads")),
)
}
#[test]
fn freezing_probes_label_sources_linearly_and_records_exact_ranges() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let interior = "flowchart LR\n A[Cache] -->|fills| B[Store]\n C[Cache] -->|reads| B";
let path = diagram_deck(
directory.path(),
&format!("## q\n```mermaid\n{interior}\n```\nanswer\n"),
);
let svg = fixture_svg();
let first = format!("<!-- {{\"id\": 1}} -->\n{svg}");
let second = ["A", "L_A_B_0", "B", "C", "L_C_B_0"]
.iter()
.enumerate()
.map(|(index, id)| format!("<!-- {{\"id\": {}}} -->\n{}", index + 1, variant_svg(id)))
.collect::<Vec<_>>()
.join("\n");
let out1 = directory.path().join("sekien-out1");
let out2 = directory.path().join("sekien-out2");
std::fs::write(&out1, first).unwrap();
std::fs::write(&out2, second).unwrap();
let counts = directory.path().join("render-counts");
let cli = crate::testutil::fake_cli(
directory.path(),
&format!(
"c=$(cat | tr -cd '\\000' | wc -c); echo $c >> {counts}; n=$(wc -l < {counts} | tr -d ' '); if [ \"$n\" = \"1\" ]; then cat {out1}; else cat {out2}; fi",
counts = counts.display(),
out1 = out1.display(),
out2 = out2.display(),
),
);
let report = freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
assert_eq!(1, report.diagrams, "{:?}", report.diagram_warnings);
let recorded: Vec<usize> = std::fs::read_to_string(&counts)
.unwrap()
.lines()
.map(|line| line.trim().parse().unwrap())
.collect();
assert_eq!(
vec![0, 4],
recorded,
"NUL separators per batch: one fence diagram (0 separators), then \
5 probes (4): one per unique occurrence per distinct text; a \
per-label pass would send 6 (5 separators)"
);
let deck = Deck::load(&path).unwrap();
let stamp = &deck.cards[0].diagrams[0];
let owned = directory.path().join("assets/deck-deck1");
let geometry: crate::diagram::DiagramGeometry =
serde_json::from_slice(&std::fs::read(owned.join(&stamp.geometry)).unwrap()).unwrap();
let range_of = |id: &str| {
let label = geometry.labels.iter().find(|label| label.id == id).unwrap();
match label.source {
crate::diagram::LabelSource::Range { start, end } => (start as usize, end as usize),
crate::diagram::LabelSource::Unbindable => panic!("{id} unbindable"),
}
};
let occurrence = |needle: &str, n: usize| {
let mut from = 0;
for _ in 0..n {
from = interior[from..].find(needle).unwrap() + from + 1;
}
(from - 1, from - 1 + needle.len())
};
assert_eq!(occurrence("Cache", 1), range_of("A"), "first Cache is A's");
assert_eq!(occurrence("Cache", 2), range_of("C"), "second Cache is C's");
assert_eq!(occurrence("Store", 1), range_of("B"));
assert_eq!(occurrence("fills", 1), range_of("L_A_B_0"));
assert_eq!(occurrence("reads", 1), range_of("L_C_B_0"));
}
#[test]
fn a_bare_node_binds_through_the_bracket_retry() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let interior = "flowchart LR\n Idle --> Done[Finished]";
let path = diagram_deck(
directory.path(),
&format!("## q\n```mermaid\n{interior}\n```\nanswer\n"),
);
let node = |id: &str, text: &str, ty: u32| {
format!(
r##"<g class="node" id="d1-flowchart-{id}-0" transform="translate(10,{ty})"><rect class="label-container" x="0" y="0" width="200" height="40" fill="none"/><g class="label"><text x="10" y="28">{text}</text></g></g>"##
)
};
let svg_of = |idle_id: &str, idle_text: &str, done_text: &str| {
format!(
r##"<svg id="d1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 120"><style>#d1{{font-family:DejaVu Sans;font-size:16px;fill:#333;}}</style>{}{}</svg>"##,
node(idle_id, idle_text, 10),
node("Done", done_text, 60),
)
};
let main = format!(
"<!-- {{\"id\": 1}} -->\n{}",
svg_of("Idle", "Idle", "Finished")
);
let plain = [
svg_of("xq1", "xq1", "Finished"),
svg_of("Idle", "Idle", "xq1"),
]
.iter()
.enumerate()
.map(|(index, svg)| format!("<!-- {{\"id\": {}}} -->\n{svg}", index + 1))
.collect::<Vec<_>>()
.join("\n");
let retry = format!(
"<!-- {{\"id\": 1}} -->\n{}",
svg_of("Idle", "xq1", "Finished")
);
let out1 = directory.path().join("sekien-out1");
let out2 = directory.path().join("sekien-out2");
let out3 = directory.path().join("sekien-out3");
std::fs::write(&out1, main).unwrap();
std::fs::write(&out2, plain).unwrap();
std::fs::write(&out3, retry).unwrap();
let counts = directory.path().join("render-counts");
let cli = crate::testutil::fake_cli(
directory.path(),
&format!(
"c=$(cat | tr -cd '\\000' | wc -c); echo $c >> {counts}; n=$(wc -l < {counts} | tr -d ' '); if [ \"$n\" = \"1\" ]; then cat {out1}; elif [ \"$n\" = \"2\" ]; then cat {out2}; else cat {out3}; fi",
counts = counts.display(),
out1 = out1.display(),
out2 = out2.display(),
out3 = out3.display(),
),
);
let report = freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
assert_eq!(1, report.diagrams, "{:?}", report.diagram_warnings);
let recorded: Vec<usize> = std::fs::read_to_string(&counts)
.unwrap()
.lines()
.map(|line| line.trim().parse().unwrap())
.collect();
assert_eq!(
vec![0, 1, 0],
recorded,
"NUL separators per batch: the fence render, 2 plain probes (1 \
separator), then exactly ONE bracket retry for the one bare \
occurrence"
);
let deck = Deck::load(&path).unwrap();
let stamp = &deck.cards[0].diagrams[0];
let owned = directory.path().join("assets/deck-deck1");
let geometry: crate::diagram::DiagramGeometry =
serde_json::from_slice(&std::fs::read(owned.join(&stamp.geometry)).unwrap()).unwrap();
let range_of = |id: &str| {
let label = geometry.labels.iter().find(|label| label.id == id).unwrap();
match label.source {
crate::diagram::LabelSource::Range { start, end } => (start as usize, end as usize),
crate::diagram::LabelSource::Unbindable => panic!("{id} unbindable"),
}
};
let start = interior.find("Idle").unwrap();
assert_eq!((start, start + "Idle".len()), range_of("Idle"));
let start = interior.find("Finished").unwrap();
assert_eq!((start, start + "Finished".len()), range_of("Done"));
}
#[test]
fn a_labeled_node_reference_cannot_license_a_bare_retry() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let interior = "flowchart LR\n A[β] --> X[A]\n A --> X";
let path = diagram_deck(
directory.path(),
&format!("## q\n```mermaid\n{interior}\n```\nanswer\n"),
);
let node = |id: &str, text: &str, y: u32| {
format!(
r##"<g class="node" id="d1-flowchart-{id}-0" transform="translate(10,{y})"><rect class="label-container" x="0" y="0" width="200" height="40" fill="none"/><g class="label"><text x="10" y="28">{text}</text></g></g>"##
)
};
let svg_of = |labels: &[(&str, &str)]| {
let nodes = labels
.iter()
.enumerate()
.map(|(index, (id, text))| node(id, text, 10 + index as u32 * 50))
.collect::<String>();
format!(
r##"<svg id="d1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 180"><style>#d1{{font-family:DejaVu Sans;font-size:16px;fill:#333;}}</style>{nodes}</svg>"##
)
};
let main = format!(
"<!-- {{\"id\": 1}} -->\n{}",
svg_of(&[("A", "β"), ("X", "A")])
);
let plain = [
svg_of(&[("xq1", "β"), ("X", "A"), ("A", "A")]),
svg_of(&[("A", "β"), ("X", "xq1")]),
svg_of(&[("A", "β"), ("X", "A"), ("xq1", "xq1")]),
]
.iter()
.enumerate()
.map(|(index, svg)| format!("<!-- {{\"id\": {}}} -->\n{svg}", index + 1))
.collect::<Vec<_>>()
.join("\n");
let retry = format!(
"<!-- {{\"id\": 1}} -->\n{}",
svg_of(&[("A", "xq1"), ("X", "A")])
);
let out1 = directory.path().join("sekien-out1");
let out2 = directory.path().join("sekien-out2");
let out3 = directory.path().join("sekien-out3");
std::fs::write(&out1, main).unwrap();
std::fs::write(&out2, plain).unwrap();
std::fs::write(&out3, retry).unwrap();
let counts = directory.path().join("render-counts");
let cli = crate::testutil::fake_cli(
directory.path(),
&format!(
"c=$(cat | tr -cd '\\000' | wc -c); echo $c >> {counts}; n=$(wc -l < {counts} | tr -d ' '); if [ \"$n\" = \"1\" ]; then cat {out1}; elif [ \"$n\" = \"2\" ]; then cat {out2}; else cat {out3}; fi",
counts = counts.display(),
out1 = out1.display(),
out2 = out2.display(),
out3 = out3.display(),
),
);
let report = freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
assert_eq!(1, report.diagrams, "{:?}", report.diagram_warnings);
let deck = Deck::load(&path).unwrap();
let stamp = &deck.cards[0].diagrams[0];
let owned = directory.path().join("assets/deck-deck1");
let geometry: crate::diagram::DiagramGeometry =
serde_json::from_slice(&std::fs::read(owned.join(&stamp.geometry)).unwrap()).unwrap();
let a = geometry
.labels
.iter()
.find(|label| label.id == "A")
.unwrap();
assert_eq!(
crate::diagram::LabelSource::Unbindable,
a.source,
"A's visible β is not literal source text, so a later A reference must not bind it"
);
let x = geometry
.labels
.iter()
.find(|label| label.id == "X")
.unwrap();
let start = interior.find("X[A]").unwrap() + 2;
assert_eq!(
crate::diagram::LabelSource::Range {
start: start as u32,
end: start as u32 + 1,
},
x.source
);
assert_eq!(
vec![0, 2],
std::fs::read_to_string(&counts)
.unwrap()
.lines()
.map(|line| line.trim().parse::<usize>().unwrap())
.collect::<Vec<_>>(),
"a labeled node reference does not schedule a bare-node retry"
);
}
#[test]
fn an_unresolvable_stamp_surfaces_one_generic_load_warning() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(
directory.path(),
"## q\n```mermaid\nflowchart LR\n A-->B\n```\nanswer\n",
);
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
let healthy = Deck::load(&path).unwrap();
assert!(
healthy.load_warnings.is_empty(),
"a healthy freeze loads quietly: {:?}",
healthy.load_warnings
);
let stamp = healthy.cards[0].diagrams[0].clone();
let owned = directory.path().join("assets/deck-deck1");
let geometry_path = owned.join(&stamp.geometry);
let original = std::fs::read_to_string(&geometry_path).unwrap();
let old_shape = {
let mut value: serde_json::Value = serde_json::from_str(&original).unwrap();
for label in value["labels"].as_array_mut().unwrap() {
label.as_object_mut().unwrap().remove("source");
}
serde_json::to_string(&value).unwrap()
};
std::fs::write(&geometry_path, old_shape).unwrap();
let deck = Deck::load(&path).unwrap();
assert!(
deck.cards[0].resolved_diagrams.is_empty(),
"a geometry without label sources is ordinary invalid input"
);
assert_eq!(
vec![
"1 frozen diagram(s) did not resolve and fall back to source; run `alix doctor` for details"
.to_string()
],
deck.load_warnings
);
let hostile = {
let mut value: serde_json::Value = serde_json::from_str(&original).unwrap();
value["labels"][0]["source"] =
serde_json::json!({"kind": "range", "start": 0, "end": 999999});
serde_json::to_string(&value).unwrap()
};
std::fs::write(&geometry_path, hostile).unwrap();
let deck = Deck::load(&path).unwrap();
assert!(
deck.cards[0].resolved_diagrams.is_empty(),
"a hostile range unresolves before anything can slice"
);
assert_eq!(1, deck.load_warnings.len(), "{:?}", deck.load_warnings);
}
#[test]
fn a_stale_stamp_surfaces_the_generic_load_warning() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(
directory.path(),
"## q\n```mermaid\nflowchart LR\n A-->B\n```\nanswer\n",
);
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
let frozen = std::fs::read_to_string(&path).unwrap();
assert!(frozen.contains("<!-- diagram:"), "precondition: {frozen}");
std::fs::write(&path, frozen.replacen(" A-->B", " A-->C", 1)).unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(
vec![
"1 frozen diagram(s) did not resolve and fall back to source; run `alix doctor` for details"
.to_string()
],
deck.load_warnings,
"editing a frozen fence makes its adjacent stamp stale, so the ordinary load channel must say that review fell back"
);
}
#[test]
fn a_duplicate_fence_cannot_hide_its_edited_siblings_stale_stamp() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let source = "flowchart LR\n A-->B";
let path = diagram_deck(
directory.path(),
&format!(
"## first\n```mermaid\n{source}\n```\nanswer one\n\n## second\n```mermaid\n{source}\n```\nanswer two\n"
),
);
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&[
format!("<!-- {{\"id\": 1}} -->\n{svg}"),
format!("<!-- {{\"id\": 2}} -->\n{svg}"),
]
.join("\0"),
"",
);
let report = freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
assert_eq!(2, report.diagrams, "{:?}", report.diagram_warnings);
let frozen = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, frozen.replacen(" A-->B", " A-->C", 1)).unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(
vec![
"1 frozen diagram(s) did not resolve and fall back to source; run `alix doctor` for details"
.to_string()
],
deck.load_warnings,
"the unchanged duplicate has the old fingerprint too, but must not make the edited fence's attached stale stamp look current"
);
}
#[test]
fn a_transient_probe_failure_is_retryable_without_editing_the_deck() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let interior = "flowchart LR\n A[Cache] -->|fills| B[Store]\n C[Cache] -->|reads| B";
let path = diagram_deck(
directory.path(),
&format!("## q\n```mermaid\n{interior}\n```\nanswer\n"),
);
let svg = fixture_svg();
let first_out = directory.path().join("probe-failure-original");
std::fs::write(&first_out, format!("<!-- {{\"id\": 1}} -->\n{svg}")).unwrap();
let first_counts = directory.path().join("probe-failure-counts");
std::fs::write(&first_counts, "").unwrap();
let failing = crate::testutil::fake_cli(
directory.path(),
&format!(
"cat >/dev/null; echo x >> {counts}; n=$(wc -l < {counts} | tr -d ' '); if [ \"$n\" = \"1\" ]; then cat {out}; fi",
counts = first_counts.display(),
out = first_out.display(),
),
);
freeze_member_with(&path, Some(failing.to_str().unwrap())).unwrap();
let retry_out1 = directory.path().join("probe-retry-original");
let retry_out2 = directory.path().join("probe-retry-variants");
std::fs::write(&retry_out1, format!("<!-- {{\"id\": 1}} -->\n{svg}")).unwrap();
std::fs::write(
&retry_out2,
["A", "L_A_B_0", "B", "C", "L_C_B_0"]
.iter()
.enumerate()
.map(|(index, id)| {
format!("<!-- {{\"id\": {}}} -->\n{}", index + 1, variant_svg(id))
})
.collect::<Vec<_>>()
.join("\n"),
)
.unwrap();
let retry_counts = directory.path().join("probe-retry-counts");
std::fs::write(&retry_counts, "").unwrap();
let healthy = crate::testutil::fake_cli(
directory.path(),
&format!(
"cat >/dev/null; echo x >> {counts}; n=$(wc -l < {counts} | tr -d ' '); if [ \"$n\" = \"1\" ]; then cat {out1}; else cat {out2}; fi",
counts = retry_counts.display(),
out1 = retry_out1.display(),
out2 = retry_out2.display(),
),
);
let retried = freeze_member_with(&path, Some(healthy.to_str().unwrap())).unwrap();
assert_eq!(
1, retried.diagrams,
"a failed probe pass must not mint an already-current stamp that prevents a clean retry: {:?}",
retried.diagram_warnings
);
}
#[test]
fn an_empty_mermaid_fence_warns_and_never_freezes() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(directory.path(), "## q\n```mermaid\n```\nanswer\n");
let cli = fake_sekien(
directory.path(),
"",
"<!-- {\"id\": 1} -->\nempty diagram input",
);
let report = freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
assert_eq!(0, report.diagrams);
assert!(
report
.diagram_warnings
.iter()
.any(|warning| warning.contains("did not render")),
"{:?}",
report.diagram_warnings
);
let frozen = std::fs::read_to_string(&path).unwrap();
assert!(
!frozen.contains("<!-- diagram:"),
"no stamp for an empty fence: {frozen}"
);
}
#[test]
fn freezing_a_member_stamps_a_mermaid_fence_and_writes_the_asset_pair() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(
directory.path(),
"## q\n```mermaid\nflowchart LR\n A-->B\n```\nanswer\n",
);
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
let report = freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
assert_eq!(1, report.diagrams, "{:?}", report.diagram_warnings);
assert!(
report.diagram_warnings.is_empty(),
"{:?}",
report.diagram_warnings
);
let frozen = std::fs::read_to_string(&path).unwrap();
let fingerprint = crate::diagram::fingerprint("flowchart LR\n A-->B");
let stamp_line = frozen
.lines()
.find(|line| line.starts_with("<!-- diagram:"))
.expect("a stamp line exists");
assert!(stamp_line.contains(&fingerprint), "{stamp_line}");
let after_close = frozen.split("```\n").nth(1).unwrap();
assert!(
after_close.starts_with("<!-- diagram:"),
"the stamp sits on the line after the fence close: {after_close}"
);
let deck = Deck::load(&path).unwrap();
let stamp = &deck.cards[0].diagrams[0];
assert_eq!(fingerprint, stamp.fingerprint);
let owned = directory.path().join("assets/deck-deck1");
let geometry_bytes = std::fs::read(owned.join(&stamp.geometry)).unwrap();
let geometry: crate::diagram::DiagramGeometry =
serde_json::from_slice(&geometry_bytes).unwrap();
assert_eq!(
stamp.asset, geometry.image,
"the stamp and geometry agree on the raster"
);
assert!(owned.join(&stamp.asset).is_file(), "the PNG object exists");
assert_eq!(5, geometry.labels.len(), "three nodes and two edge labels");
assert_eq!(
(geometry.logical_width * 2, geometry.logical_height * 2),
(geometry.image_width, geometry.image_height),
"raster is the logical size at ZOOM"
);
validate_member(&deck).expect("both objects verify");
}
#[test]
fn a_label_whose_ink_escapes_its_box_warns_and_does_not_freeze() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(
directory.path(),
"## q\n```mermaid\nflowchart LR\n A-->B\n```\nanswer\n",
);
let svg =
fixture_svg().replacen("width=\"110\" height=\"49\"", "width=\"1\" height=\"1\"", 1);
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
let report = freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
assert_eq!(0, report.diagrams);
assert_eq!(1, report.diagram_warnings.len());
assert!(
report.diagram_warnings[0].contains("ink-containment")
|| report.diagram_warnings[0].contains("no ink"),
"{}",
report.diagram_warnings[0]
);
assert!(
!std::fs::read_to_string(&path)
.unwrap()
.contains("<!-- diagram:"),
"a failed validation freezes nothing"
);
assert!(
!directory.path().join("assets/deck-deck1").exists(),
"no objects are written for a refused fence"
);
}
#[test]
fn a_fence_closing_at_eof_without_a_newline_still_gets_a_wellformed_stamp() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(
directory.path(),
"## q\nanswer\n```mermaid\nflowchart LR\n A-->B\n```",
);
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
let report = freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
assert_eq!(1, report.diagrams, "{:?}", report.diagram_warnings);
let deck = Deck::load(&path).unwrap();
assert_eq!(
1,
deck.cards[0].diagrams.len(),
"a glued stamp would unclose the fence and never parse as a directive"
);
}
#[test]
fn refreezing_needs_no_renderer_and_rewrites_nothing() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(
directory.path(),
"## q\n```mermaid\nflowchart LR\n A-->B\n```\nanswer\n",
);
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
let first = std::fs::read_to_string(&path).unwrap();
let report = freeze_member_with(&path, Some("definitely-not-a-real-binary-xyz")).unwrap();
assert_eq!(0, report.diagrams);
assert!(
report.diagram_warnings.is_empty(),
"a fully stamped deck never invokes the renderer: {:?}",
report.diagram_warnings
);
assert_eq!(first, std::fs::read_to_string(&path).unwrap());
}
#[test]
fn an_edited_fence_replaces_its_stamp_in_place() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(
directory.path(),
"## q\n```mermaid\nflowchart LR\n A-->B\n```\nanswer\n",
);
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
let stamped = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, stamped.replace("A-->B", "A-->C")).unwrap();
freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
let refrozen = std::fs::read_to_string(&path).unwrap();
let stamps: Vec<&str> = refrozen
.lines()
.filter(|line| line.starts_with("<!-- diagram:"))
.collect();
assert_eq!(1, stamps.len(), "replaced, never stacked: {stamps:?}");
assert!(
stamps[0].contains(&crate::diagram::fingerprint("flowchart LR\n A-->C")),
"the stamp follows the edited fence: {}",
stamps[0]
);
}
#[test]
fn an_indented_stamp_stays_idempotent_and_a_stale_one_replaces_cleanly() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(
directory.path(),
"## q\n```mermaid\nflowchart LR\n A-->B\n```\nanswer\n",
);
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
let frozen = std::fs::read_to_string(&path).unwrap();
let indented = frozen.replace("<!-- diagram:", " <!-- diagram:");
std::fs::write(&path, &indented).unwrap();
let report = freeze_member_with(&path, Some("definitely-not-a-real-binary-xyz")).unwrap();
assert_eq!(0, report.diagrams, "an indented matching stamp is honored");
assert!(
report.diagram_warnings.is_empty(),
"{:?}",
report.diagram_warnings
);
assert_eq!(indented, std::fs::read_to_string(&path).unwrap());
std::fs::write(&path, indented.replace("A-->B", "A-->C")).unwrap();
freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
let refrozen = std::fs::read_to_string(&path).unwrap();
let stamps: Vec<&str> = refrozen
.lines()
.filter(|line| line.trim_start().starts_with("<!-- diagram:"))
.collect();
assert_eq!(1, stamps.len(), "replaced, never stacked: {stamps:?}");
assert!(
stamps[0].starts_with("<!-- diagram:"),
"the rewrite normalizes the indentation: {:?}",
stamps[0]
);
assert!(
stamps[0].contains(&crate::diagram::fingerprint("flowchart LR\n A-->C")),
"{}",
stamps[0]
);
}
#[test]
fn a_malformed_near_stamp_never_suppresses_freezing() {
let _guard = crate::testutil::exec_lock();
for junk in ["\u{00a0}{stamp}", "<!-- diagram: fingerprint: {print}"] {
let directory = workspace();
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
let path = diagram_deck(
directory.path(),
"## q\n```mermaid\nflowchart LR\n A-->B\n```\nanswer\n",
);
freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
let frozen = std::fs::read_to_string(&path).unwrap();
let stamp_line = frozen
.lines()
.find(|line| line.starts_with("<!-- diagram:"))
.unwrap()
.to_string();
let print = crate::diagram::fingerprint("flowchart LR\n A-->B");
let junk_line = junk
.replace("{stamp}", &stamp_line)
.replace("{print}", &print);
std::fs::write(&path, frozen.replace(&stamp_line, &junk_line)).unwrap();
let report = freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
assert_eq!(
1, report.diagrams,
"{junk_line}: re-freezes, never a silent no-op"
);
let repaired = std::fs::read_to_string(&path).unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(
1,
deck.cards[0].diagrams.len(),
"{junk_line}: a real stamp exists again"
);
assert!(
repaired.contains(junk_line.trim_end_matches('\n')),
"{junk_line}: the junk stays visible for the author"
);
}
}
#[test]
fn a_crlf_text_gets_a_crlf_terminated_stamp() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
let text = "## q\r\n```mermaid\r\nflowchart LR\r\n A-->B\r\n```\r\nanswer\r\n";
let outcome = freeze_diagrams(
directory.path(),
"deck-deck1",
text,
None,
Some(cli.to_str().unwrap()),
)
.unwrap();
assert_eq!(1, outcome.frozen, "{:?}", outcome.warnings);
let (_, inserted) = &outcome.replacements[0];
assert!(
inserted.ends_with("-->\r\n"),
"the stamp inherits the CRLF terminator: {inserted:?}"
);
assert!(
inserted.contains(&crate::diagram::fingerprint("flowchart LR\n A-->B")),
"the fingerprint is the LF-normalized one: {inserted:?}"
);
}
#[test]
fn a_crlf_text_with_a_close_at_eof_gets_a_crlf_terminated_stamp() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
let text = "## q\r\n```mermaid\r\nflowchart LR\r\n A-->B\r\n```";
let outcome = freeze_diagrams(
directory.path(),
"deck-deck1",
text,
None,
Some(cli.to_str().unwrap()),
)
.unwrap();
assert_eq!(1, outcome.frozen, "{:?}", outcome.warnings);
let (_, inserted) = &outcome.replacements[0];
assert!(
inserted.starts_with("\r\n<!-- diagram:") && inserted.ends_with("-->\r\n"),
"a CRLF deck without a final newline must not gain LF endings: {inserted:?}"
);
}
#[test]
fn a_frozen_deck_loads_with_resolved_diagrams_and_a_mismatch_unresolves() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(
directory.path(),
"## q\n```mermaid\nflowchart LR\n A-->B\n```\nanswer\n",
);
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(1, deck.cards[0].resolved_diagrams.len());
let resolved = &deck.cards[0].resolved_diagrams[0];
assert!(
resolved.png.is_file(),
"the resolved path points at the raster"
);
assert_eq!(resolved.geometry.image, deck.cards[0].diagrams[0].asset);
assert_eq!(
(
resolved.geometry.logical_width * 2,
resolved.geometry.logical_height * 2
),
(
resolved.geometry.image_width,
resolved.geometry.image_height
),
);
let owned = directory.path().join("assets/deck-deck1");
let geometry_path = owned.join(&deck.cards[0].diagrams[0].geometry);
let mut geometry: crate::diagram::DiagramGeometry =
serde_json::from_slice(&std::fs::read(&geometry_path).unwrap()).unwrap();
geometry.image = format!("sha256-{}.png", "b".repeat(64));
std::fs::write(&geometry_path, serde_json::to_vec(&geometry).unwrap()).unwrap();
let deck = Deck::load(&path).unwrap();
assert!(
deck.cards[0].resolved_diagrams.is_empty(),
"a geometry naming a different raster must not resolve"
);
}
#[test]
fn validation_requires_both_stamped_objects() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(
directory.path(),
"## q\n```mermaid\nflowchart LR\n A-->B\n```\nanswer\n",
);
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
let deck = Deck::load(&path).unwrap();
let stamp = deck.cards[0].diagrams[0].clone();
let owned = directory.path().join("assets/deck-deck1");
for name in [&stamp.asset, &stamp.geometry] {
let object = owned.join(name);
let bytes = std::fs::read(&object).unwrap();
std::fs::remove_file(&object).unwrap();
assert!(
matches!(
validate_member(&deck),
Err(AssetError::MissingAsset(missing)) if missing == object
),
"deleting {name} must fail validation"
);
std::fs::write(&object, bytes).unwrap();
}
validate_member(&deck).expect("restored objects validate again");
}
#[test]
fn repair_removes_orphan_stamps_and_refreezes_stale_fences() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(
directory.path(),
"## q\n```mermaid\nflowchart LR\n A-->B\n```\nanswer\n",
);
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 1}} -->\n{svg}"),
"",
);
freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
let frozen = std::fs::read_to_string(&path).unwrap();
let stale = frozen.replace("A-->B", "A-->C");
std::fs::write(&path, &stale).unwrap();
let (removed, report) = repair_diagrams_with(&path, Some(cli.to_str().unwrap())).unwrap();
assert_eq!(
(0, 1),
(removed, report.diagrams),
"a stale fence re-freezes"
);
let orphaned = std::fs::read_to_string(&path)
.unwrap()
.lines()
.filter(|line| !line.starts_with("```") && !line.contains("A-->C"))
.collect::<Vec<_>>()
.join("\n")
+ "\n";
std::fs::write(&path, &orphaned).unwrap();
let (removed, report) =
repair_diagrams_with(&path, Some("definitely-not-a-real-binary-xyz")).unwrap();
assert_eq!(1, removed, "the fence is gone, so its stamp goes too");
assert_eq!(0, report.diagrams);
assert!(
report.diagram_warnings.is_empty(),
"{:?}",
report.diagram_warnings
);
let repaired = std::fs::read_to_string(&path).unwrap();
assert!(!repaired.contains("<!-- diagram:"), "{repaired}");
let (removed, _) =
repair_diagrams_with(&path, Some("definitely-not-a-real-binary-xyz")).unwrap();
assert_eq!(0, removed, "the repair is idempotent");
}
#[test]
fn a_missing_renderer_warns_with_the_doctor_remedy_and_freezes_the_rest() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let source = directory.path().join("notes.md");
std::fs::write(&source, "one\ntwo\n").unwrap();
let path = directory.path().join("decks/graphs.md");
std::fs::write(
&path,
format!(
"---\nformat-version: 1\nid: \"deck-deck1\"\nsource: {}\n---\n## q\n```mermaid\nflowchart LR\n A-->B\n```\na\n<!-- at: notes.md:2 -->\n",
crate::parser::yaml_quote(&source.display().to_string())
),
)
.unwrap();
let report = freeze_member_with(&path, Some("definitely-not-a-real-binary-xyz")).unwrap();
assert_eq!(1, report.evidence, "the citation still froze");
assert_eq!(0, report.diagrams);
assert_eq!(1, report.diagram_warnings.len());
assert!(
report.diagram_warnings[0].contains(crate::diagram::REMEDY),
"the warning carries the doctor remedy: {}",
report.diagram_warnings[0]
);
assert!(
!std::fs::read_to_string(&path)
.unwrap()
.contains("<!-- diagram:")
);
}
#[test]
fn a_failed_diagram_warns_while_the_healthy_one_freezes() {
let _guard = crate::testutil::exec_lock();
let directory = workspace();
let path = diagram_deck(
directory.path(),
"## q\n```mermaid\nnot a diagram\n```\nanswer\n## q2\n```mermaid\nflowchart LR\n A-->B\n```\nanswer\n",
);
let svg = fixture_svg();
let cli = fake_sekien(
directory.path(),
&format!("<!-- {{\"id\": 2}} -->\n{svg}"),
"<!-- {\"id\": 1} -->\nNo diagram type detected",
);
let report = freeze_member_with(&path, Some(cli.to_str().unwrap())).unwrap();
assert_eq!(1, report.diagrams);
assert_eq!(1, report.diagram_warnings.len());
assert!(
report.diagram_warnings[0].contains("did not render")
&& report.diagram_warnings[0]
.contains(&crate::diagram::fingerprint("not a diagram")),
"{}",
report.diagram_warnings[0]
);
let frozen = std::fs::read_to_string(&path).unwrap();
assert_eq!(
1,
frozen
.lines()
.filter(|line| line.starts_with("<!-- diagram:"))
.count(),
"only the healthy fence is stamped"
);
}
}