use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use thiserror::Error;
use tracing::trace;
use crate::artifact::{
ARTIFACT_DIRECTORY_NAME, EntryArtifact, EntryArtifactPath, EntryArtifactPathError,
};
use crate::check::{CheckMode, CheckReport, CheckSeverity};
use crate::entry::{
Entry, EntryParseError, EntryRenderError, FrozenMarker, has_mixed_line_endings,
};
use crate::freeze::FrozenPath;
use crate::identifier::EntryAtom;
use crate::identifier::{EntryAddress, EntryAddressError};
use crate::render::{GeneratedLinkBody, GeneratedLinkError};
use crate::structural::{StructuralEdgeIndex, StructuralSettings};
use crate::witness::{WitnessCheckSettings, WitnessError};
const READONLY_CHECKOUT_WARNING: &str = "\
> This file is a read-only Sirno Frost checkout.
> Do not edit it by hand.
";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EntryDirectory {
root: PathBuf,
}
#[derive(Debug)]
pub struct EntryDirectoryReport {
root: PathBuf,
entries: Vec<Entry>,
artifacts: Vec<EntryArtifact>,
paths_by_address: BTreeMap<EntryAddress, PathBuf>,
file_diagnostics: Vec<EntryFileDiagnostic>,
structural_report: CheckReport,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EntryDirectoryCheckSettings {
pub render: bool,
pub structural_inhabitance: bool,
pub structural: StructuralSettings,
pub ignore: Vec<PathBuf>,
pub witness: Option<WitnessCheckSettings>,
}
impl Default for EntryDirectoryCheckSettings {
fn default() -> Self {
Self {
render: true,
structural_inhabitance: true,
structural: StructuralSettings::default(),
ignore: Vec::new(),
witness: None,
}
}
}
impl EntryDirectoryCheckSettings {
pub fn ignores(&self, relative_path: &Path) -> bool {
self.ignore.iter().any(|ignored| {
!ignored.as_os_str().is_empty()
&& (relative_path == ignored || relative_path.starts_with(ignored))
})
}
}
impl EntryDirectoryReport {
pub fn root(&self) -> &Path {
&self.root
}
pub fn entries(&self) -> &[Entry] {
&self.entries
}
pub fn artifacts(&self) -> &[EntryArtifact] {
&self.artifacts
}
pub fn file_diagnostics(&self) -> &[EntryFileDiagnostic] {
&self.file_diagnostics
}
pub fn structural_report(&self) -> &CheckReport {
&self.structural_report
}
pub fn entry_file_path(&self, id: &EntryAddress) -> Option<&Path> {
self.paths_by_address.get(id).map(PathBuf::as_path)
}
pub fn is_clean(&self) -> bool {
self.file_diagnostics.is_empty() && self.structural_report.is_clean()
}
pub fn has_errors(&self) -> bool {
self.file_diagnostics.iter().any(|diagnostic| diagnostic.severity == CheckSeverity::Error)
|| self.structural_report.has_errors()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EntryFileDiagnostic {
pub severity: CheckSeverity,
pub path: PathBuf,
pub message: String,
}
#[derive(Debug)]
pub struct GenLinkDirectoryReport {
root: PathBuf,
entry_count: usize,
changed_paths: Vec<PathBuf>,
}
impl GenLinkDirectoryReport {
pub fn root(&self) -> &Path {
&self.root
}
pub fn entry_count(&self) -> usize {
self.entry_count
}
pub fn changed_paths(&self) -> &[PathBuf] {
&self.changed_paths
}
}
#[derive(Debug)]
pub struct EntryRenameReport {
old_id: EntryAddress,
new_id: EntryAddress,
changed_paths: Vec<PathBuf>,
}
#[derive(Debug)]
pub struct EntryProtectionReport {
root: PathBuf,
paths: Vec<PathBuf>,
}
#[derive(Debug)]
pub struct GlacierReport {
root: PathBuf,
domain: EntryAtom,
changed_paths: Vec<PathBuf>,
}
impl EntryRenameReport {
pub fn old_id(&self) -> &EntryAddress {
&self.old_id
}
pub fn new_id(&self) -> &EntryAddress {
&self.new_id
}
pub fn changed_paths(&self) -> &[PathBuf] {
&self.changed_paths
}
}
impl EntryProtectionReport {
pub fn root(&self) -> &Path {
&self.root
}
pub fn paths(&self) -> &[PathBuf] {
&self.paths
}
}
impl GlacierReport {
pub fn root(&self) -> &Path {
&self.root
}
pub fn domain(&self) -> &EntryAtom {
&self.domain
}
pub fn changed_paths(&self) -> &[PathBuf] {
&self.changed_paths
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EntryDirectoryWritePolicy {
EmptyDirectory,
ReplaceDirectory {
ignore: Vec<PathBuf>,
},
}
impl EntryFileDiagnostic {
pub fn new(
severity: CheckSeverity, path: impl Into<PathBuf>, message: impl Into<String>,
) -> Self {
Self { severity, path: path.into(), message: message.into() }
}
}
impl EntryDirectory {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn entry_artifact_root_path(&self, id: &EntryAddress) -> PathBuf {
self.entry_artifact_directory(id)
}
pub fn entry_artifact_path(&self, id: &EntryAddress, path: &EntryArtifactPath) -> PathBuf {
self.entry_artifact_directory(id).join(path.to_path_buf())
}
pub fn entry_exists(&self, id: &EntryAddress) -> Result<bool, EntryDirectoryError> {
if !self.root.exists() {
return Err(EntryDirectoryError::MissingDirectory(self.root.clone()));
}
if !self.root.is_dir() {
return Err(EntryDirectoryError::NotDirectory(self.root.clone()));
}
let path = self.entry_file_path(id);
match fs::symlink_metadata(path) {
| Ok(metadata) => Ok(metadata.file_type().is_file()),
| Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
| Err(source) => Err(source.into()),
}
}
pub fn read_entry_source(&self, id: &EntryAddress) -> Result<String, EntryDirectoryError> {
if !self.root.exists() {
return Err(EntryDirectoryError::MissingDirectory(self.root.clone()));
}
if !self.root.is_dir() {
return Err(EntryDirectoryError::NotDirectory(self.root.clone()));
}
let path = self.entry_file_path(id);
match fs::symlink_metadata(&path) {
| Ok(metadata) if metadata.file_type().is_file() => {}
| Ok(_) => return Err(EntryDirectoryError::EntryNotFound(id.clone())),
| Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Err(EntryDirectoryError::EntryNotFound(id.clone()));
}
| Err(source) => return Err(source.into()),
}
Ok(fs::read_to_string(path)?)
}
pub fn read_entry(&self, id: &EntryAddress) -> Result<Entry, EntryDirectoryError> {
let source = self.read_entry_source(id)?;
Ok(Entry::from_markdown(id.clone(), &source)?)
}
pub fn read_entry_artifacts(
&self, id: &EntryAddress,
) -> Result<Vec<EntryArtifact>, EntryDirectoryError> {
if !self.root.exists() {
return Err(EntryDirectoryError::MissingDirectory(self.root.clone()));
}
if !self.root.is_dir() {
return Err(EntryDirectoryError::NotDirectory(self.root.clone()));
}
let owner_root = self.entry_artifact_directory(id);
if !owner_root.exists() {
return Ok(Vec::new());
}
if !owner_root.is_dir() {
return Err(EntryDirectoryError::CheckoutConflict(owner_root));
}
let mut artifacts = Vec::new();
for path in sorted_recursive_paths(&owner_root)? {
let file_type = fs::symlink_metadata(&path)?.file_type();
if file_type.is_dir() {
continue;
}
if !file_type.is_file() {
return Err(EntryDirectoryError::CheckoutConflict(path));
}
let relative_path = path.strip_prefix(&owner_root).map_err(|source| {
EntryDirectoryError::StripRoot {
path: path.clone(),
root: owner_root.clone(),
source,
}
})?;
let artifact_path = EntryArtifactPath::new(relative_path)?;
artifacts.push(EntryArtifact::new(id.clone(), artifact_path, fs::read(path)?));
}
artifacts.sort_by(|left, right| left.path.cmp(&right.path));
Ok(artifacts)
}
pub fn add_entry_artifact(
&self, id: &EntryAddress, source: &Path, artifact_path: &EntryArtifactPath,
) -> Result<PathBuf, EntryDirectoryError> {
self.ensure_entry_artifacts_mutable(id)?;
match fs::symlink_metadata(source) {
| Ok(metadata) if metadata.file_type().is_file() => {}
| Ok(_) => {
return Err(EntryDirectoryError::ArtifactSourceNotFile(source.to_path_buf()));
}
| Err(source) => return Err(source.into()),
}
let path = self.entry_artifact_path(id, artifact_path);
if path.exists() {
return Err(EntryDirectoryError::ArtifactAlreadyExists {
owner: id.clone(),
path: artifact_path.clone(),
});
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(source, &path)?;
Ok(path)
}
pub fn rename_entry_artifact(
&self, id: &EntryAddress, old_path: &EntryArtifactPath, new_path: &EntryArtifactPath,
) -> Result<PathBuf, EntryDirectoryError> {
self.ensure_entry_artifacts_mutable(id)?;
if old_path == new_path {
return Err(EntryDirectoryError::ArtifactRenameSamePath {
owner: id.clone(),
path: old_path.clone(),
});
}
let source = self.entry_artifact_path(id, old_path);
match fs::symlink_metadata(&source) {
| Ok(metadata) if metadata.file_type().is_file() => {}
| Ok(_) => {
return Err(EntryDirectoryError::ArtifactNotFound {
owner: id.clone(),
path: old_path.clone(),
});
}
| Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Err(EntryDirectoryError::ArtifactNotFound {
owner: id.clone(),
path: old_path.clone(),
});
}
| Err(source) => return Err(source.into()),
}
let destination = self.entry_artifact_path(id, new_path);
if destination.exists() {
return Err(EntryDirectoryError::ArtifactAlreadyExists {
owner: id.clone(),
path: new_path.clone(),
});
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::rename(&source, &destination).map_err(|source_error| {
EntryDirectoryError::RenameFile {
source_path: source.clone(),
destination_path: destination.clone(),
source: source_error,
}
})?;
self.remove_empty_artifact_parents(id, old_path)?;
Ok(destination)
}
pub fn remove_entry_artifact(
&self, id: &EntryAddress, artifact_path: &EntryArtifactPath,
) -> Result<PathBuf, EntryDirectoryError> {
self.ensure_entry_artifacts_mutable(id)?;
let path = self.entry_artifact_path(id, artifact_path);
match fs::symlink_metadata(&path) {
| Ok(metadata) if metadata.file_type().is_file() => {}
| Ok(_) => {
return Err(EntryDirectoryError::ArtifactNotFound {
owner: id.clone(),
path: artifact_path.clone(),
});
}
| Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Err(EntryDirectoryError::ArtifactNotFound {
owner: id.clone(),
path: artifact_path.clone(),
});
}
| Err(source) => return Err(source.into()),
}
set_path_writable(&path)?;
fs::remove_file(&path)?;
self.remove_empty_artifact_parents(id, artifact_path)?;
Ok(path)
}
pub fn check(&self, mode: CheckMode) -> Result<EntryDirectoryReport, EntryDirectoryError> {
self.check_with_settings(mode, &EntryDirectoryCheckSettings::default())
}
pub fn check_with_settings(
&self, mode: CheckMode, settings: &EntryDirectoryCheckSettings,
) -> Result<EntryDirectoryReport, EntryDirectoryError> {
trace!("check_entry_directory begin: root={}", self.root.display());
let loaded = LoadedEntryDirectory::load(&self.root, mode, settings)?;
let structural_report = mode.check_entries_with_structural_inhabitance(
&loaded.entries,
&settings.structural,
settings.structural_inhabitance,
);
trace!(
"check_entry_directory end: entries={} file_diagnostics={} structural_diagnostics={}",
loaded.entries.len(),
loaded.file_diagnostics.len(),
structural_report.diagnostics().len()
);
Ok(EntryDirectoryReport {
root: self.root.clone(),
entries: loaded.entries,
artifacts: loaded.artifacts,
paths_by_address: loaded.paths_by_address,
file_diagnostics: loaded.file_diagnostics,
structural_report,
})
}
pub fn init(&self) -> Result<Vec<PathBuf>, EntryDirectoryError> {
trace!("init_entry_directory begin: root={}", self.root.display());
fs::create_dir_all(&self.root)?;
let mut paths = Vec::new();
for entry in Entry::default_seed_entries()? {
let path = self.write_new_entry_file(&entry)?;
paths.push(path);
}
trace!("init_entry_directory end: entries={}", paths.len());
Ok(paths)
}
pub fn create_entry(&self, entry: &Entry) -> Result<PathBuf, EntryDirectoryError> {
trace!("create_entry_file begin: root={} id={}", self.root.display(), entry.id);
fs::create_dir_all(&self.root)?;
let path = self.write_new_entry_file(entry)?;
trace!("create_entry_file end: path={}", path.display());
Ok(path)
}
pub fn freeze_entry(&self, id: &EntryAddress) -> Result<PathBuf, EntryDirectoryError> {
self.set_entry_frozen(id, true)
}
pub fn melt_entry(&self, id: &EntryAddress) -> Result<PathBuf, EntryDirectoryError> {
self.set_entry_frozen(id, false)
}
pub fn rename_entry(
&self, old_id: &EntryAddress, new_id: &EntryAddress, settings: &EntryDirectoryCheckSettings,
) -> Result<EntryRenameReport, EntryDirectoryError> {
trace!(
"rename_entry begin: root={} old_id={} new_id={}",
self.root.display(),
old_id,
new_id
);
if old_id == new_id {
return Err(EntryDirectoryError::RenameSameId(old_id.clone()));
}
let mut check_settings = settings.clone();
check_settings.render = false;
let checked = self.check_with_settings(CheckMode::Review, &check_settings)?;
if checked.has_errors() {
return Err(EntryDirectoryError::InvalidEntryDirectory(self.root.clone()));
}
if checked.entry_file_path(old_id).is_none() {
return Err(EntryDirectoryError::EntryNotFound(old_id.clone()));
}
if checked
.entries()
.iter()
.any(|entry| &entry.id == old_id && entry.metadata.meta.frozen.is_some())
{
return Err(EntryDirectoryError::FrozenEntryProtected(old_id.clone()));
}
let new_path = self.entry_file_path(new_id);
if let Some(parent) = new_path.parent() {
fs::create_dir_all(parent)?;
}
match fs::symlink_metadata(&new_path) {
| Ok(_) => {
return Err(EntryDirectoryError::EntryAlreadyExists {
id: new_id.clone(),
path: new_path,
});
}
| Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
| Err(source) => return Err(source.into()),
}
let mut renamed_structural = settings.structural.clone();
let rename_structural_field = renamed_structural.rename_field(old_id, new_id);
let mut entries = Vec::<(EntryAddress, Entry, bool)>::new();
for entry in checked.entries() {
let original_id = entry.id.clone();
let mut entry = entry.clone();
if &entry.id == old_id {
entry.id = new_id.clone();
}
let mut content_changed = entry.metadata.rename_structural_target(old_id, new_id);
if rename_structural_field {
content_changed |= entry.metadata.rename_structural_field(old_id, new_id);
}
entries.push((original_id, entry, content_changed));
}
let indexed_entries = entries.iter().map(|(_, entry, _)| entry.clone()).collect::<Vec<_>>();
let link_index = StructuralEdgeIndex::from_entries(&indexed_entries);
let mut changed_paths = Vec::new();
for (original_id, mut entry, mut content_changed) in entries {
if content_changed && entry.metadata.meta.frozen.is_some() {
return Err(EntryDirectoryError::FrozenEntryProtected(original_id));
}
let source_path = checked
.entry_file_path(&original_id)
.ok_or_else(|| EntryDirectoryError::MissingEntryFilePath(original_id.clone()))?;
let destination_path =
if &original_id == old_id { new_path.as_path() } else { source_path };
let footer = link_index.render_entry(&entry, &renamed_structural);
let body = GeneratedLinkBody::new(&entry.body);
if body.is_stale(&footer)? {
entry.body = body.apply(&footer)?;
content_changed = true;
}
if content_changed && entry.metadata.meta.frozen.is_some() {
return Err(EntryDirectoryError::FrozenEntryProtected(original_id.clone()));
}
if &original_id == old_id {
set_path_writable(source_path)?;
fs::rename(source_path, destination_path).map_err(|source| {
EntryDirectoryError::RenameFile {
source_path: source_path.to_path_buf(),
destination_path: destination_path.to_path_buf(),
source,
}
})?;
if content_changed {
let rendered = entry.to_markdown()?;
set_path_writable(destination_path)?;
fs::write(destination_path, rendered).map_err(|source| {
EntryDirectoryError::WriteFile {
path: destination_path.to_path_buf(),
source,
}
})?;
}
if entry.metadata.meta.frozen.is_some() {
freeze_path_best_effort(destination_path)?;
}
changed_paths.push(destination_path.to_path_buf());
continue;
}
if content_changed {
let rendered = entry.to_markdown()?;
set_path_writable(source_path)?;
fs::write(source_path, rendered).map_err(|source| {
EntryDirectoryError::WriteFile { path: source_path.to_path_buf(), source }
})?;
if entry.metadata.meta.frozen.is_some() {
freeze_path_best_effort(source_path)?;
}
changed_paths.push(source_path.to_path_buf());
}
}
let old_artifacts = self.entry_artifact_directory(old_id);
if old_artifacts.exists() {
if !old_artifacts.is_dir() {
return Err(EntryDirectoryError::CheckoutConflict(old_artifacts));
}
let new_artifacts = self.entry_artifact_directory(new_id);
if new_artifacts.exists() {
return Err(EntryDirectoryError::EntryAlreadyExists {
id: new_id.clone(),
path: new_artifacts,
});
}
set_path_writable(&self.artifact_root())?;
fs::rename(&old_artifacts, &new_artifacts).map_err(|source| {
EntryDirectoryError::RenameFile {
source_path: old_artifacts.clone(),
destination_path: new_artifacts.clone(),
source,
}
})?;
changed_paths.push(new_artifacts);
}
changed_paths.sort();
changed_paths.dedup();
trace!("rename_entry end: changed={}", changed_paths.len());
Ok(EntryRenameReport { old_id: old_id.clone(), new_id: new_id.clone(), changed_paths })
}
pub fn write(
&self, entries: &[Entry], policy: EntryDirectoryWritePolicy,
) -> Result<Vec<PathBuf>, EntryDirectoryError> {
self.write_with_artifacts(entries, &[], policy)
}
pub fn write_with_artifacts(
&self, entries: &[Entry], artifacts: &[EntryArtifact], policy: EntryDirectoryWritePolicy,
) -> Result<Vec<PathBuf>, EntryDirectoryError> {
trace!(
"write_entry_directory begin: root={} entries={}",
self.root.display(),
entries.len()
);
self.prepare_target(policy)?;
let mut paths = Vec::new();
for entry in entries {
paths.push(self.write_new_entry_file(entry)?);
}
paths.extend(self.write_entry_artifacts(entries, artifacts)?);
trace!("write_entry_directory end: entries={}", paths.len());
Ok(paths)
}
pub fn replace_glacier(
&self, domain: &EntryAtom, entries: &[Entry], artifacts: &[EntryArtifact],
settings: &EntryDirectoryCheckSettings,
) -> Result<GlacierReport, EntryDirectoryError> {
trace!("replace_glacier begin: root={} domain={}", self.root.display(), domain);
fs::create_dir_all(&self.root)?;
for entry in entries {
if !entry.id.starts_with_domain(domain) {
return Err(EntryDirectoryError::GlacierEntryOutsideDomain {
domain: domain.clone(),
id: entry.id.clone(),
});
}
if !entry.metadata.meta.frozen.as_ref().is_some_and(|marker| marker.is_managed()) {
return Err(EntryDirectoryError::GlacierEntryNotManaged(entry.id.clone()));
}
}
self.ensure_glacier_replaceable(domain, settings)?;
let mut changed_paths = Vec::new();
changed_paths.extend(self.remove_glacier_entries(domain, settings)?);
changed_paths.extend(self.remove_glacier_artifacts(domain)?);
for entry in entries {
changed_paths.push(self.write_new_entry_file(entry)?);
}
changed_paths.extend(self.write_entry_artifacts(entries, artifacts)?);
changed_paths.sort();
changed_paths.dedup();
trace!("replace_glacier end: domain={} changed={}", domain, changed_paths.len());
Ok(GlacierReport { root: self.root.clone(), domain: domain.clone(), changed_paths })
}
pub fn ensure_glacier_replaceable(
&self, domain: &EntryAtom, settings: &EntryDirectoryCheckSettings,
) -> Result<(), EntryDirectoryError> {
self.ensure_glacier_entries_replaceable(domain, settings)?;
self.ensure_glacier_artifacts_replaceable(domain)?;
Ok(())
}
pub fn set_readonly(
&self, settings: &EntryDirectoryCheckSettings,
) -> Result<(), EntryDirectoryError> {
self.set_writability(settings, false)
}
pub fn set_writable(
&self, settings: &EntryDirectoryCheckSettings,
) -> Result<(), EntryDirectoryError> {
self.set_writability(settings, true)
}
pub fn clear_local_protection(
&self, settings: &EntryDirectoryCheckSettings, dry_run: bool,
) -> Result<EntryProtectionReport, EntryDirectoryError> {
self.require_existing_directory()?;
let paths = self.local_protection_paths(settings)?;
if !dry_run {
for path in &paths {
melt_path_best_effort(path)?;
}
}
Ok(EntryProtectionReport { root: self.root.clone(), paths })
}
pub fn fix_local_protection(
&self, settings: &EntryDirectoryCheckSettings, protect_checkout: bool, dry_run: bool,
) -> Result<EntryProtectionReport, EntryDirectoryError> {
self.require_existing_directory()?;
let paths = if protect_checkout {
self.local_protection_paths(settings)?
} else {
self.frozen_entry_protection_paths(settings)?
};
if !dry_run {
if protect_checkout {
self.set_readonly(settings)?;
} else {
self.fix_frozen_entry_protection(settings)?;
}
}
Ok(EntryProtectionReport { root: self.root.clone(), paths })
}
pub fn add_readonly_checkout_warnings(
&self, paths: &[PathBuf],
) -> Result<(), EntryDirectoryError> {
trace!("add_readonly_checkout_warnings begin: root={}", self.root.display());
for path in paths {
let source = fs::read_to_string(path)?;
let source = add_readonly_checkout_warning(path, &source)?;
fs::write(path, source)
.map_err(|source| EntryDirectoryError::WriteFile { path: path.clone(), source })?;
}
Ok(())
}
pub fn generate_links(
&self, settings: &StructuralSettings,
) -> Result<GenLinkDirectoryReport, EntryDirectoryError> {
self.generate_links_with_ignored_paths(settings, Vec::<PathBuf>::new())
}
pub fn check_generated_links(
&self, settings: &StructuralSettings,
) -> Result<GenLinkDirectoryReport, EntryDirectoryError> {
self.check_generated_links_with_ignored_paths(settings, Vec::<PathBuf>::new())
}
pub fn generate_links_with_ignored_paths(
&self, settings: &StructuralSettings, ignore: impl IntoIterator<Item = PathBuf>,
) -> Result<GenLinkDirectoryReport, EntryDirectoryError> {
self.process_generated_links(settings, ignore, true, GenLinkOperation::Write)
}
pub fn generate_links_with_check_settings(
&self, settings: &EntryDirectoryCheckSettings,
) -> Result<GenLinkDirectoryReport, EntryDirectoryError> {
self.process_generated_links(
&settings.structural,
settings.ignore.clone(),
settings.structural_inhabitance,
GenLinkOperation::Write,
)
}
pub fn generate_links_for_crystallization(
&self, settings: &EntryDirectoryCheckSettings,
) -> Result<GenLinkDirectoryReport, EntryDirectoryError> {
self.process_generated_links(
&settings.structural,
settings.ignore.clone(),
settings.structural_inhabitance,
GenLinkOperation::WriteManaged,
)
}
pub fn check_generated_links_with_ignored_paths(
&self, settings: &StructuralSettings, ignore: impl IntoIterator<Item = PathBuf>,
) -> Result<GenLinkDirectoryReport, EntryDirectoryError> {
self.process_generated_links(settings, ignore, true, GenLinkOperation::Check)
}
pub fn check_generated_links_with_check_settings(
&self, settings: &EntryDirectoryCheckSettings,
) -> Result<GenLinkDirectoryReport, EntryDirectoryError> {
self.process_generated_links(
&settings.structural,
settings.ignore.clone(),
settings.structural_inhabitance,
GenLinkOperation::Check,
)
}
fn process_generated_links(
&self, settings: &StructuralSettings, ignore: impl IntoIterator<Item = PathBuf>,
structural_inhabitance: bool, operation: GenLinkOperation,
) -> Result<GenLinkDirectoryReport, EntryDirectoryError> {
trace!(
"gen_link_entry_directory begin: root={} operation={}",
self.root.display(),
operation.label()
);
let check_settings = EntryDirectoryCheckSettings {
render: false,
structural_inhabitance,
structural: settings.clone(),
ignore: ignore.into_iter().collect(),
witness: None,
};
let checked = self.check_with_settings(CheckMode::Review, &check_settings)?;
if checked.has_errors() {
return Err(EntryDirectoryError::InvalidEntryDirectory(self.root.clone()));
}
let mut changed_paths = Vec::new();
let index = StructuralEdgeIndex::from_entries(checked.entries());
for entry in checked.entries() {
let path = checked
.entry_file_path(&entry.id)
.ok_or_else(|| EntryDirectoryError::MissingEntryFilePath(entry.id.clone()))?;
let source = fs::read_to_string(path)?;
let footer = index.render_entry(entry, settings);
let body = GeneratedLinkBody::new(&entry.body).apply(&footer)?;
let rendered = Entry::replace_markdown_body(&source, &body)?;
if rendered != source {
if operation.writes() {
if !operation.allows_entry_write(entry) {
return Err(EntryDirectoryError::FrozenEntryProtected(entry.id.clone()));
}
fs::write(path, rendered).map_err(|source| EntryDirectoryError::WriteFile {
path: path.to_path_buf(),
source,
})?;
}
changed_paths.push(path.to_path_buf());
}
}
trace!(
"gen_link_entry_directory end: entries={} changed={}",
checked.entries().len(),
changed_paths.len()
);
Ok(GenLinkDirectoryReport {
root: self.root.clone(),
entry_count: checked.entries().len(),
changed_paths,
})
}
pub fn delete_generated_links(&self) -> Result<GenLinkDirectoryReport, EntryDirectoryError> {
self.delete_generated_links_with_ignored_paths(Vec::<PathBuf>::new())
}
pub fn delete_generated_links_with_ignored_paths(
&self, ignore: impl IntoIterator<Item = PathBuf>,
) -> Result<GenLinkDirectoryReport, EntryDirectoryError> {
trace!("delete_gen_link_entry_directory begin: root={}", self.root.display());
let check_settings = EntryDirectoryCheckSettings {
render: false,
structural_inhabitance: true,
structural: StructuralSettings::default(),
ignore: ignore.into_iter().collect(),
witness: None,
};
let checked = self.check_with_settings(CheckMode::Edit, &check_settings)?;
if checked.has_errors() {
return Err(EntryDirectoryError::InvalidEntryDirectory(self.root.clone()));
}
let mut changed_paths = Vec::new();
for entry in checked.entries() {
let path = checked
.entry_file_path(&entry.id)
.ok_or_else(|| EntryDirectoryError::MissingEntryFilePath(entry.id.clone()))?;
let source = fs::read_to_string(path)?;
let body = GeneratedLinkBody::new(&entry.body).delete()?;
let rendered = Entry::replace_markdown_body(&source, &body)?;
if rendered != source {
if entry.metadata.meta.frozen.is_some() {
return Err(EntryDirectoryError::FrozenEntryProtected(entry.id.clone()));
}
fs::write(path, rendered).map_err(|source| EntryDirectoryError::WriteFile {
path: path.to_path_buf(),
source,
})?;
changed_paths.push(path.to_path_buf());
}
}
trace!(
"delete_gen_link_entry_directory end: entries={} changed={}",
checked.entries().len(),
changed_paths.len()
);
Ok(GenLinkDirectoryReport {
root: self.root.clone(),
entry_count: checked.entries().len(),
changed_paths,
})
}
fn write_new_entry_file(&self, entry: &Entry) -> Result<PathBuf, EntryDirectoryError> {
let path = self.entry_file_path(&entry.id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let source = entry.to_markdown()?;
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|source| EntryDirectoryError::CreateFile { path: path.clone(), source })?;
file.write_all(source.as_bytes())
.map_err(|source| EntryDirectoryError::WriteFile { path: path.clone(), source })?;
Ok(path)
}
fn write_entry_artifacts(
&self, entries: &[Entry], artifacts: &[EntryArtifact],
) -> Result<Vec<PathBuf>, EntryDirectoryError> {
if artifacts.is_empty() {
return Ok(Vec::new());
}
let entry_addresses = entries.iter().map(|entry| entry.id.clone()).collect::<BTreeSet<_>>();
let mut seen = BTreeSet::<(EntryAddress, EntryArtifactPath)>::new();
let mut paths = Vec::new();
for artifact in artifacts {
if !entry_addresses.contains(&artifact.owner) {
return Err(EntryDirectoryError::EntryNotFound(artifact.owner.clone()));
}
if !seen.insert((artifact.owner.clone(), artifact.path.clone())) {
return Err(EntryDirectoryError::DuplicateArtifact {
owner: artifact.owner.clone(),
path: artifact.path.clone(),
});
}
let path =
self.entry_artifact_directory(&artifact.owner).join(artifact.path.to_path_buf());
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file =
OpenOptions::new().write(true).create_new(true).open(&path).map_err(|source| {
EntryDirectoryError::CreateFile { path: path.clone(), source }
})?;
file.write_all(&artifact.content)
.map_err(|source| EntryDirectoryError::WriteFile { path: path.clone(), source })?;
paths.push(path);
}
Ok(paths)
}
fn set_entry_frozen(
&self, id: &EntryAddress, frozen: bool,
) -> Result<PathBuf, EntryDirectoryError> {
if !self.root.exists() {
return Err(EntryDirectoryError::MissingDirectory(self.root.clone()));
}
if !self.root.is_dir() {
return Err(EntryDirectoryError::NotDirectory(self.root.clone()));
}
let path = self.entry_file_path(id);
let source = fs::read_to_string(&path)?;
let mut entry = Entry::from_markdown(id.clone(), &source)?;
if frozen {
match &mut entry.metadata.meta.frozen {
| Some(marker) => marker.insert_reviewed(),
| None => entry.metadata.meta.frozen = Some(FrozenMarker::reviewed()),
}
} else if let Some(marker) = &mut entry.metadata.meta.frozen
&& !marker.remove_reviewed()
{
entry.metadata.meta.frozen = None;
}
let still_frozen = entry.metadata.meta.frozen.is_some();
let rendered = entry.to_markdown()?;
if !frozen {
melt_path_best_effort(&path)?;
}
if rendered != source {
set_path_writable(&path)?;
fs::write(&path, rendered)
.map_err(|source| EntryDirectoryError::WriteFile { path: path.clone(), source })?;
}
if frozen || still_frozen {
freeze_path_best_effort(&path)?;
self.set_entry_artifact_writability(id, false)?;
} else {
set_path_writable(&path)?;
self.set_entry_artifact_writability(id, true)?;
}
Ok(path)
}
fn prepare_target(&self, policy: EntryDirectoryWritePolicy) -> Result<(), EntryDirectoryError> {
match policy {
| EntryDirectoryWritePolicy::EmptyDirectory => {
if self.root.exists() {
if !self.root.is_dir() {
return Err(EntryDirectoryError::NotDirectory(self.root.clone()));
}
if fs::read_dir(&self.root)?.next().is_some() {
return Err(EntryDirectoryError::DirectoryNotEmpty(self.root.clone()));
}
} else {
fs::create_dir_all(&self.root)?;
}
}
| EntryDirectoryWritePolicy::ReplaceDirectory { ignore } => {
if self.root.exists() {
if !self.root.is_dir() {
return Err(EntryDirectoryError::NotDirectory(self.root.clone()));
}
melt_path_best_effort(&self.root)?;
let settings = EntryDirectoryCheckSettings {
ignore,
witness: None,
..EntryDirectoryCheckSettings::default()
};
self.remove_managed_entry_files(&settings)?;
} else {
fs::create_dir_all(&self.root)?;
}
}
}
Ok(())
}
fn remove_managed_entry_files(
&self, settings: &EntryDirectoryCheckSettings,
) -> Result<(), EntryDirectoryError> {
self.remove_managed_entry_files_in(&self.root, settings)
}
fn remove_glacier_entries(
&self, domain: &EntryAtom, settings: &EntryDirectoryCheckSettings,
) -> Result<Vec<PathBuf>, EntryDirectoryError> {
let domain_root = self.root.join(domain.as_str());
if !domain_root.exists() {
return Ok(Vec::new());
}
if !domain_root.is_dir() {
return Err(EntryDirectoryError::CheckoutConflict(domain_root));
}
let mut changed = Vec::new();
for path in sorted_recursive_paths(&domain_root)?.into_iter().rev() {
let metadata = fs::symlink_metadata(&path)?;
if metadata.file_type().is_dir() {
if fs::read_dir(&path)?.next().is_none() {
fs::remove_dir(&path)?;
changed.push(path);
}
continue;
}
if settings.ignores(path.strip_prefix(&self.root).map_err(|source| {
EntryDirectoryError::StripRoot {
path: path.clone(),
root: self.root.clone(),
source,
}
})?) {
continue;
}
if !metadata.file_type().is_file()
|| path.extension().and_then(|extension| extension.to_str()) != Some("md")
{
return Err(EntryDirectoryError::CheckoutConflict(path));
}
let relative =
path.strip_prefix(&self.root).map_err(|source| EntryDirectoryError::StripRoot {
path: path.clone(),
root: self.root.clone(),
source,
})?;
let id = EntryAddress::from_lake_relative_path(relative)?;
let entry = self.read_entry(&id)?;
if !entry.metadata.meta.frozen.as_ref().is_some_and(|marker| marker.is_managed()) {
return Err(EntryDirectoryError::UnmanagedGlacierPath(path));
}
melt_path_best_effort(&path)?;
fs::remove_file(&path)?;
changed.push(path);
}
if fs::read_dir(&domain_root)?.next().is_none() {
fs::remove_dir(&domain_root)?;
changed.push(domain_root);
}
Ok(changed)
}
fn ensure_glacier_entries_replaceable(
&self, domain: &EntryAtom, settings: &EntryDirectoryCheckSettings,
) -> Result<(), EntryDirectoryError> {
let domain_root = self.root.join(domain.as_str());
if !domain_root.exists() {
return Ok(());
}
if !domain_root.is_dir() {
return Err(EntryDirectoryError::CheckoutConflict(domain_root));
}
for path in sorted_recursive_paths(&domain_root)? {
let metadata = fs::symlink_metadata(&path)?;
if metadata.file_type().is_dir() {
continue;
}
if settings.ignores(path.strip_prefix(&self.root).map_err(|source| {
EntryDirectoryError::StripRoot {
path: path.clone(),
root: self.root.clone(),
source,
}
})?) {
continue;
}
if !metadata.file_type().is_file()
|| path.extension().and_then(|extension| extension.to_str()) != Some("md")
{
return Err(EntryDirectoryError::CheckoutConflict(path));
}
let relative =
path.strip_prefix(&self.root).map_err(|source| EntryDirectoryError::StripRoot {
path: path.clone(),
root: self.root.clone(),
source,
})?;
let id = EntryAddress::from_lake_relative_path(relative)?;
let entry = self.read_entry(&id)?;
if !entry.metadata.meta.frozen.as_ref().is_some_and(|marker| marker.is_managed()) {
return Err(EntryDirectoryError::UnmanagedGlacierPath(path));
}
}
Ok(())
}
fn remove_glacier_artifacts(
&self, domain: &EntryAtom,
) -> Result<Vec<PathBuf>, EntryDirectoryError> {
let artifact_root = self.artifact_root();
if !artifact_root.exists() {
return Ok(Vec::new());
}
if !artifact_root.is_dir() {
return Err(EntryDirectoryError::CheckoutConflict(artifact_root));
}
let mut changed = Vec::new();
for owner_root in sorted_directory_paths(&artifact_root)? {
let Some(owner_name) = owner_root.file_name().and_then(|name| name.to_str()) else {
continue;
};
let Ok(owner) = EntryAddress::new(owner_name) else {
continue;
};
if !owner.starts_with_domain(domain) {
continue;
}
let owner_entry = self.entry_file_path(&owner);
if owner_entry.exists() {
let entry = self.read_entry(&owner)?;
if !entry.metadata.meta.frozen.as_ref().is_some_and(|marker| marker.is_managed()) {
return Err(EntryDirectoryError::UnmanagedGlacierPath(owner_root));
}
}
melt_tree_best_effort(&owner_root)?;
fs::remove_dir_all(&owner_root)?;
changed.push(owner_root);
}
Ok(changed)
}
fn ensure_glacier_artifacts_replaceable(
&self, domain: &EntryAtom,
) -> Result<(), EntryDirectoryError> {
let artifact_root = self.artifact_root();
if !artifact_root.exists() {
return Ok(());
}
if !artifact_root.is_dir() {
return Err(EntryDirectoryError::CheckoutConflict(artifact_root));
}
for owner_root in sorted_directory_paths(&artifact_root)? {
let Some(owner_name) = owner_root.file_name().and_then(|name| name.to_str()) else {
continue;
};
let Ok(owner) = EntryAddress::new(owner_name) else {
continue;
};
if !owner.starts_with_domain(domain) {
continue;
}
let owner_entry = self.entry_file_path(&owner);
if owner_entry.exists() {
let entry = self.read_entry(&owner)?;
if !entry.metadata.meta.frozen.as_ref().is_some_and(|marker| marker.is_managed()) {
return Err(EntryDirectoryError::UnmanagedGlacierPath(owner_root));
}
}
}
Ok(())
}
fn remove_managed_entry_files_in(
&self, directory: &Path, settings: &EntryDirectoryCheckSettings,
) -> Result<(), EntryDirectoryError> {
for path in sorted_directory_paths(directory)? {
let relative_path =
path.strip_prefix(&self.root).map_err(|source| EntryDirectoryError::StripRoot {
path: path.clone(),
root: self.root.clone(),
source,
})?;
if settings.ignores(relative_path) {
continue;
}
let file_type = fs::symlink_metadata(&path)?.file_type();
if relative_path == Path::new(ARTIFACT_DIRECTORY_NAME) && file_type.is_dir() {
melt_tree_best_effort(&path)?;
fs::remove_dir_all(&path)?;
continue;
}
if is_reserved_builtin_root(relative_path) {
return Err(EntryDirectoryError::CheckoutConflict(path));
}
if file_type.is_dir() {
self.remove_managed_entry_files_in(&path, settings)?;
if fs::read_dir(&path)?.next().is_none() {
fs::remove_dir(&path)?;
}
continue;
}
if file_type.is_file()
&& path.extension().and_then(|extension| extension.to_str()) == Some("md")
&& Self::is_managed_entry_file(&path)?
{
melt_path_best_effort(&path)?;
fs::remove_file(&path)?;
continue;
}
return Err(EntryDirectoryError::CheckoutConflict(path));
}
Ok(())
}
fn is_managed_entry_file(path: &Path) -> Result<bool, EntryDirectoryError> {
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
return Ok(false);
};
let Ok(id) = EntryAddress::new(stem) else {
return Ok(false);
};
let source = fs::read_to_string(path)?;
Ok(Entry::from_markdown(id, &source).is_ok())
}
fn is_frozen_entry_file(path: &Path) -> Result<bool, EntryDirectoryError> {
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
return Ok(false);
};
let Ok(id) = EntryAddress::new(stem) else {
return Ok(false);
};
let source = fs::read_to_string(path)?;
Ok(Entry::from_markdown(id, &source)
.map(|entry| entry.metadata.meta.frozen.is_some())
.unwrap_or(false))
}
fn require_existing_directory(&self) -> Result<(), EntryDirectoryError> {
if !self.root.exists() {
return Err(EntryDirectoryError::MissingDirectory(self.root.clone()));
}
if !self.root.is_dir() {
return Err(EntryDirectoryError::NotDirectory(self.root.clone()));
}
Ok(())
}
fn local_protection_paths(
&self, settings: &EntryDirectoryCheckSettings,
) -> Result<Vec<PathBuf>, EntryDirectoryError> {
let mut paths = vec![self.root.clone()];
for path in sorted_recursive_paths(&self.root)? {
let relative_path =
path.strip_prefix(&self.root).map_err(|source| EntryDirectoryError::StripRoot {
path: path.clone(),
root: self.root.clone(),
source,
})?;
if settings.ignores(relative_path) {
continue;
}
paths.push(path);
}
paths.sort();
paths.dedup();
Ok(paths)
}
fn frozen_entry_protection_paths(
&self, settings: &EntryDirectoryCheckSettings,
) -> Result<Vec<PathBuf>, EntryDirectoryError> {
let checked = self.check_with_settings(CheckMode::Edit, settings)?;
if checked.has_errors() {
return Err(EntryDirectoryError::InvalidEntryDirectory(self.root.clone()));
}
let mut paths = Vec::new();
for entry in checked.entries().iter().filter(|entry| entry.metadata.meta.frozen.is_some()) {
let path = checked
.entry_file_path(&entry.id)
.ok_or_else(|| EntryDirectoryError::MissingEntryFilePath(entry.id.clone()))?;
paths.push(path.to_path_buf());
self.push_entry_artifact_tree_paths(&entry.id, &mut paths)?;
}
paths.sort();
paths.dedup();
Ok(paths)
}
fn push_entry_artifact_tree_paths(
&self, id: &EntryAddress, paths: &mut Vec<PathBuf>,
) -> Result<(), EntryDirectoryError> {
let owner_root = self.entry_artifact_directory(id);
if !owner_root.exists() {
return Ok(());
}
if !owner_root.is_dir() {
return Err(EntryDirectoryError::CheckoutConflict(owner_root));
}
paths.push(owner_root.clone());
paths.extend(sorted_recursive_paths(&owner_root)?);
Ok(())
}
fn fix_frozen_entry_protection(
&self, settings: &EntryDirectoryCheckSettings,
) -> Result<(), EntryDirectoryError> {
let checked = self.check_with_settings(CheckMode::Edit, settings)?;
if checked.has_errors() {
return Err(EntryDirectoryError::InvalidEntryDirectory(self.root.clone()));
}
for entry in checked.entries().iter().filter(|entry| entry.metadata.meta.frozen.is_some()) {
let path = checked
.entry_file_path(&entry.id)
.ok_or_else(|| EntryDirectoryError::MissingEntryFilePath(entry.id.clone()))?;
freeze_path_best_effort(path)?;
self.set_entry_artifact_writability(&entry.id, false)?;
}
Ok(())
}
fn set_writability(
&self, settings: &EntryDirectoryCheckSettings, writable: bool,
) -> Result<(), EntryDirectoryError> {
self.require_existing_directory()?;
if writable {
melt_path_best_effort(&self.root)?;
}
self.set_child_writability(&self.root, settings, writable)?;
if !writable {
freeze_path_best_effort(&self.root)?;
}
Ok(())
}
fn set_child_writability(
&self, directory: &Path, settings: &EntryDirectoryCheckSettings, writable: bool,
) -> Result<(), EntryDirectoryError> {
for path in sorted_directory_paths(directory)? {
let relative_path =
path.strip_prefix(&self.root).map_err(|source| EntryDirectoryError::StripRoot {
path: path.clone(),
root: self.root.clone(),
source,
})?;
if settings.ignores(relative_path) {
continue;
}
let file_type = fs::symlink_metadata(&path)?.file_type();
if writable {
if self.is_frozen_managed_path(&path)? {
continue;
}
melt_path_best_effort(&path)?;
}
if file_type.is_dir() {
self.set_child_writability(&path, settings, writable)?;
}
if !writable {
freeze_path_best_effort(&path)?;
}
}
Ok(())
}
pub fn entry_file_path(&self, id: &EntryAddress) -> PathBuf {
self.root.join(id.to_lake_relative_path())
}
fn artifact_root(&self) -> PathBuf {
self.root.join(ARTIFACT_DIRECTORY_NAME)
}
fn entry_artifact_directory(&self, id: &EntryAddress) -> PathBuf {
self.artifact_root().join(id.as_str())
}
fn is_entry_file_path(&self, path: &Path) -> bool {
path.starts_with(&self.root)
&& path.extension().and_then(|extension| extension.to_str()) == Some("md")
}
fn is_frozen_managed_path(&self, path: &Path) -> Result<bool, EntryDirectoryError> {
if self.is_entry_file_path(path) && Self::is_frozen_entry_file(path)? {
return Ok(true);
}
let artifact_root = self.artifact_root();
if let Ok(relative) = path.strip_prefix(&artifact_root) {
let Some(owner) = relative.components().next().and_then(|component| match component {
| Component::Normal(owner) => owner.to_str(),
| _ => None,
}) else {
return Ok(false);
};
let Ok(owner) = EntryAddress::new(owner) else {
return Ok(false);
};
let owner_entry = self.entry_file_path(&owner);
if !owner_entry.exists() {
return Ok(false);
}
return Self::is_frozen_entry_file(&owner_entry);
}
Ok(false)
}
fn set_entry_artifact_writability(
&self, id: &EntryAddress, writable: bool,
) -> Result<(), EntryDirectoryError> {
let owner_root = self.entry_artifact_directory(id);
if !owner_root.exists() {
return Ok(());
}
if !owner_root.is_dir() {
return Err(EntryDirectoryError::CheckoutConflict(owner_root));
}
let paths = sorted_recursive_paths(&owner_root)?;
if writable {
set_path_writable(&self.artifact_root())?;
set_path_writable(&owner_root)?;
for path in paths {
melt_path_best_effort(&path)?;
}
return Ok(());
}
for path in paths.iter().rev() {
freeze_path_best_effort(path)?;
}
freeze_path_best_effort(&owner_root)
}
fn ensure_entry_artifacts_mutable(&self, id: &EntryAddress) -> Result<(), EntryDirectoryError> {
let entry = self.read_entry(id)?;
if entry.metadata.meta.frozen.is_some() {
return Err(EntryDirectoryError::FrozenEntryProtected(id.clone()));
}
Ok(())
}
fn remove_empty_artifact_parents(
&self, id: &EntryAddress, artifact_path: &EntryArtifactPath,
) -> Result<(), EntryDirectoryError> {
let owner_root = self.entry_artifact_directory(id);
let Some(mut directory) =
self.entry_artifact_path(id, artifact_path).parent().map(Path::to_path_buf)
else {
return Ok(());
};
while directory.starts_with(&owner_root) {
if fs::read_dir(&directory)?.next().is_some() {
break;
}
fs::remove_dir(&directory)?;
if directory == owner_root {
break;
}
let Some(parent) = directory.parent().map(Path::to_path_buf) else {
break;
};
directory = parent;
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum GenLinkOperation {
Check,
Write,
WriteManaged,
}
impl GenLinkOperation {
fn label(self) -> &'static str {
match self {
| Self::Check => "check",
| Self::Write => "write",
| Self::WriteManaged => "write-managed",
}
}
fn writes(self) -> bool {
matches!(self, Self::Write | Self::WriteManaged)
}
fn allows_entry_write(self, entry: &Entry) -> bool {
match &entry.metadata.meta.frozen {
| None => true,
| Some(marker) => self == Self::WriteManaged && marker.is_managed(),
}
}
}
#[derive(Debug)]
struct LoadedEntryDirectory {
entries: Vec<Entry>,
artifacts: Vec<EntryArtifact>,
paths_by_address: BTreeMap<EntryAddress, PathBuf>,
file_diagnostics: Vec<EntryFileDiagnostic>,
}
impl LoadedEntryDirectory {
fn load(
root: &Path, mode: CheckMode, settings: &EntryDirectoryCheckSettings,
) -> Result<Self, EntryDirectoryError> {
if !root.exists() {
return Err(EntryDirectoryError::MissingDirectory(root.to_path_buf()));
}
if !root.is_dir() {
return Err(EntryDirectoryError::NotDirectory(root.to_path_buf()));
}
let non_entry_severity = mode.severity();
let mut entries = Vec::new();
let mut paths_by_address = BTreeMap::<EntryAddress, PathBuf>::new();
let mut seen_ids = BTreeSet::<EntryAddress>::new();
let mut file_diagnostics = Vec::new();
let mut artifact_root = None;
let entry_paths = collect_entry_file_paths(
root,
root,
settings,
non_entry_severity,
&mut artifact_root,
&mut file_diagnostics,
)?;
for path in entry_paths {
let relative_path =
path.strip_prefix(root).map_err(|source| EntryDirectoryError::StripRoot {
path: path.clone(),
root: root.to_path_buf(),
source,
})?;
let id = match EntryAddress::from_lake_relative_path(relative_path) {
| Ok(id) => id,
| Err(source) => {
file_diagnostics.push(EntryFileDiagnostic::new(
CheckSeverity::Error,
&path,
format!("entry file path is not a valid entry address: {source}"),
));
continue;
}
};
if seen_ids.contains(&id) {
let first_path = paths_by_address
.get(&id)
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<unknown>".to_owned());
file_diagnostics.push(EntryFileDiagnostic::new(
CheckSeverity::Error,
&path,
format!("entry address `{id}` also appears at {first_path}"),
));
continue;
}
let source = fs::read_to_string(&path)?;
if has_mixed_line_endings(&source) {
file_diagnostics.push(EntryFileDiagnostic::new(
CheckSeverity::Warning,
&path,
"entry file uses mixed LF and CRLF line endings",
));
}
let entry = match Entry::from_markdown(id.clone(), &source) {
| Ok(entry) => entry,
| Err(source) => {
file_diagnostics.push(EntryFileDiagnostic::new(
CheckSeverity::Error,
&path,
format!("failed to parse entry: {source}"),
));
continue;
}
};
seen_ids.insert(id.clone());
paths_by_address.insert(id, path);
entries.push(entry);
}
entries.sort_by(|left, right| left.id.cmp(&right.id));
let mut loaded =
Self { entries, artifacts: Vec::new(), paths_by_address, file_diagnostics };
loaded.load_artifacts(root, artifact_root.as_deref(), mode)?;
loaded.add_generated_link_diagnostics(mode, settings)?;
loaded.add_witness_diagnostics(mode, settings)?;
Ok(loaded)
}
fn load_artifacts(
&mut self, root: &Path, artifact_root: Option<&Path>, mode: CheckMode,
) -> Result<(), EntryDirectoryError> {
let Some(artifact_root) = artifact_root else {
return Ok(());
};
let severity = mode.severity();
let file_type = fs::symlink_metadata(artifact_root)?.file_type();
if !file_type.is_dir() {
self.file_diagnostics.push(EntryFileDiagnostic::new(
severity,
artifact_root,
"entry artifact storage must be a directory",
));
return Ok(());
}
let ids = self.entries.iter().map(|entry| entry.id.clone()).collect::<BTreeSet<_>>();
for owner_path in sorted_directory_paths(artifact_root)? {
let owner_type = fs::symlink_metadata(&owner_path)?.file_type();
if !owner_type.is_dir() {
self.file_diagnostics.push(EntryFileDiagnostic::new(
severity,
&owner_path,
"entry artifact storage contains an unsupported filesystem item",
));
continue;
}
let Some(owner_name) = owner_path.file_name().and_then(|name| name.to_str()) else {
self.file_diagnostics.push(EntryFileDiagnostic::new(
CheckSeverity::Error,
&owner_path,
"entry artifact directory name must be valid UTF-8",
));
continue;
};
let owner = match EntryAddress::new(owner_name) {
| Ok(owner) => owner,
| Err(source) => {
self.file_diagnostics.push(EntryFileDiagnostic::new(
CheckSeverity::Error,
&owner_path,
format!(
"entry artifact directory name is not a valid entry address: {source}"
),
));
continue;
}
};
if !ids.contains(&owner) {
self.file_diagnostics.push(EntryFileDiagnostic::new(
severity,
&owner_path,
format!("entry artifact directory references missing entry `{owner}`"),
));
continue;
}
self.load_entry_artifacts(root, &owner_path, &owner, severity)?;
}
self.artifacts.sort_by(|left, right| {
left.owner.cmp(&right.owner).then_with(|| left.path.cmp(&right.path))
});
Ok(())
}
fn load_entry_artifacts(
&mut self, root: &Path, owner_root: &Path, owner: &EntryAddress, severity: CheckSeverity,
) -> Result<(), EntryDirectoryError> {
for path in sorted_recursive_paths(owner_root)? {
let file_type = fs::symlink_metadata(&path)?.file_type();
if file_type.is_dir() {
continue;
}
if !file_type.is_file() {
self.file_diagnostics.push(EntryFileDiagnostic::new(
severity,
&path,
"entry artifact tree contains an unsupported filesystem item",
));
continue;
}
let relative_path =
path.strip_prefix(owner_root).map_err(|source| EntryDirectoryError::StripRoot {
path: path.clone(),
root: root.to_path_buf(),
source,
})?;
let artifact_path = match EntryArtifactPath::new(relative_path) {
| Ok(path) => path,
| Err(source) => {
self.file_diagnostics.push(EntryFileDiagnostic::new(
CheckSeverity::Error,
&path,
format!("invalid entry artifact path: {source}"),
));
continue;
}
};
let content = fs::read(&path)?;
self.artifacts.push(EntryArtifact::new(owner.clone(), artifact_path, content));
}
Ok(())
}
fn add_generated_link_diagnostics(
&mut self, mode: CheckMode, settings: &EntryDirectoryCheckSettings,
) -> Result<(), EntryDirectoryError> {
let index = StructuralEdgeIndex::from_entries(&self.entries);
for entry in &self.entries {
let path = self
.paths_by_address
.get(&entry.id)
.ok_or_else(|| EntryDirectoryError::MissingEntryFilePath(entry.id.clone()))?;
let body = GeneratedLinkBody::new(&entry.body);
match body.validate() {
| Ok(()) if settings.render => {
let expected = index.render_entry(entry, &settings.structural);
if body.is_stale(&expected)? {
self.file_diagnostics.push(EntryFileDiagnostic::new(
mode.severity(),
path,
"generated links are stale; run `sirno render`",
));
}
}
| Ok(()) => {}
| Err(source) => {
self.file_diagnostics.push(EntryFileDiagnostic::new(
CheckSeverity::Error,
path,
format!("malformed generated links: {source}"),
));
}
}
}
Ok(())
}
fn add_witness_diagnostics(
&mut self, mode: CheckMode, settings: &EntryDirectoryCheckSettings,
) -> Result<(), EntryDirectoryError> {
let Some(witness) = &settings.witness else {
return Ok(());
};
if witness.is_empty() {
return Ok(());
}
let index = witness.scan()?;
let ids = self.entries.iter().map(|entry| entry.id.clone()).collect::<BTreeSet<_>>();
let severity = mode.severity();
for witness_path in index.entry_addresses() {
if ids.contains(witness_path) {
continue;
}
for record in index.records_for(witness_path) {
self.file_diagnostics.push(EntryFileDiagnostic::new(
severity,
&record.path,
format!("repository witness block references missing entry `{witness_path}`"),
));
}
}
for delimiter in index.orphan_delimiters() {
self.file_diagnostics.push(EntryFileDiagnostic::new(
severity,
delimiter.path(),
delimiter.diagnostic_message(),
));
}
Ok(())
}
}
fn collect_entry_file_paths(
root: &Path, directory: &Path, settings: &EntryDirectoryCheckSettings, severity: CheckSeverity,
artifact_root: &mut Option<PathBuf>, diagnostics: &mut Vec<EntryFileDiagnostic>,
) -> Result<Vec<PathBuf>, EntryDirectoryError> {
let mut entries = Vec::new();
for path in sorted_directory_paths(directory)? {
let relative_path = path.strip_prefix(root).map_err(|source| {
EntryDirectoryError::StripRoot { path: path.clone(), root: root.to_path_buf(), source }
})?;
if settings.ignores(relative_path) {
continue;
}
let file_type = fs::symlink_metadata(&path)?.file_type();
if relative_path == Path::new(ARTIFACT_DIRECTORY_NAME) {
*artifact_root = Some(path);
continue;
}
if is_reserved_builtin_root(relative_path) {
diagnostics.push(EntryFileDiagnostic::new(
severity,
&path,
"entry directory contains reserved built-in path",
));
continue;
}
if file_type.is_dir() {
entries.extend(collect_entry_file_paths(
root,
&path,
settings,
severity,
artifact_root,
diagnostics,
)?);
continue;
}
if !file_type.is_file() {
diagnostics.push(EntryFileDiagnostic::new(
severity,
&path,
"entry directory contains unsupported filesystem item",
));
continue;
}
if path.extension().and_then(|extension| extension.to_str()) != Some("md") {
diagnostics.push(EntryFileDiagnostic::new(
severity,
&path,
"entry directory contains non-Markdown file",
));
continue;
}
entries.push(path);
}
Ok(entries)
}
fn is_reserved_builtin_root(relative_path: &Path) -> bool {
relative_path.components().next().is_some_and(|component| match component {
| Component::Normal(name) => name.to_str().is_some_and(|name| name.starts_with('.')),
| _ => false,
})
}
fn sorted_directory_paths(root: &Path) -> Result<Vec<PathBuf>, EntryDirectoryError> {
let mut paths = fs::read_dir(root)?
.map(|entry| entry.map(|entry| entry.path()))
.collect::<Result<Vec<_>, _>>()?;
paths.sort();
Ok(paths)
}
fn sorted_recursive_paths(root: &Path) -> Result<Vec<PathBuf>, EntryDirectoryError> {
let mut paths = Vec::new();
collect_sorted_recursive_paths(root, &mut paths)?;
paths.sort();
Ok(paths)
}
fn collect_sorted_recursive_paths(
root: &Path, paths: &mut Vec<PathBuf>,
) -> Result<(), EntryDirectoryError> {
for path in sorted_directory_paths(root)? {
if fs::symlink_metadata(&path)?.file_type().is_dir() {
collect_sorted_recursive_paths(&path, paths)?;
}
paths.push(path);
}
Ok(())
}
fn add_readonly_checkout_warning(path: &Path, source: &str) -> Result<String, EntryDirectoryError> {
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
return Err(EntryDirectoryError::CheckoutConflict(path.to_path_buf()));
};
let id = EntryAddress::new(stem)
.map_err(|_| EntryDirectoryError::CheckoutConflict(path.to_path_buf()))?;
let entry = Entry::from_markdown(id, source)?;
if entry.body.starts_with(READONLY_CHECKOUT_WARNING) {
return Ok(source.to_owned());
}
let body = format!("{READONLY_CHECKOUT_WARNING}{}", entry.body);
Ok(Entry::replace_markdown_body(source, &body)?)
}
fn freeze_path_best_effort(path: &Path) -> Result<(), EntryDirectoryError> {
set_path_writable_flag(path, false)?;
if let Err(source) = FrozenPath::new(path).freeze() {
trace!("immutable freeze unavailable: path={} error={source}", path.display());
}
Ok(())
}
fn melt_path_best_effort(path: &Path) -> Result<(), EntryDirectoryError> {
if let Err(source) = FrozenPath::new(path).melt() {
trace!("immutable melt unavailable: path={} error={source}", path.display());
}
set_path_writable(path)
}
fn melt_tree_best_effort(root: &Path) -> Result<(), EntryDirectoryError> {
for path in sorted_recursive_paths(root)?.iter().rev() {
melt_path_best_effort(path)?;
}
melt_path_best_effort(root)
}
fn set_path_writable(path: &Path) -> Result<(), EntryDirectoryError> {
set_path_writable_flag(path, true)
}
fn set_path_writable_flag(path: &Path, writable: bool) -> Result<(), EntryDirectoryError> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() {
return Ok(());
}
let mut permissions = metadata.permissions();
set_permissions_writable(&mut permissions, metadata.file_type().is_dir(), writable);
fs::set_permissions(path, permissions)?;
Ok(())
}
#[cfg(unix)]
fn set_permissions_writable(permissions: &mut fs::Permissions, is_directory: bool, writable: bool) {
let mode = permissions.mode();
let next = if writable {
if is_directory { mode | 0o700 } else { mode | 0o600 }
} else {
mode & !0o222
};
permissions.set_mode(next);
}
#[cfg(not(unix))]
fn set_permissions_writable(
permissions: &mut fs::Permissions, _is_directory: bool, writable: bool,
) {
permissions.set_readonly(!writable);
}
#[derive(Debug, Error)]
pub enum EntryDirectoryError {
#[error("entry directory does not exist: {0}")]
MissingDirectory(PathBuf),
#[error("entry address is not a directory: {0}")]
NotDirectory(PathBuf),
#[error("entry directory must be empty before checkout: {0}")]
DirectoryNotEmpty(PathBuf),
#[error("checkout conflict at unmanaged path: {0}")]
CheckoutConflict(PathBuf),
#[error("entry rename source and destination are both `{0}`")]
RenameSameId(EntryAddress),
#[error("entry `{0}` does not exist")]
EntryNotFound(EntryAddress),
#[error("entry `{id}` already exists at {path}")]
EntryAlreadyExists {
id: EntryAddress,
path: PathBuf,
},
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("entry address {path} is not inside entry directory {root}")]
StripRoot {
path: PathBuf,
root: PathBuf,
#[source]
source: std::path::StripPrefixError,
},
#[error(transparent)]
EntryParse(#[from] EntryParseError),
#[error(transparent)]
EntryAddress(#[from] EntryAddressError),
#[error(transparent)]
ArtifactPath(#[from] EntryArtifactPathError),
#[error(transparent)]
Render(#[from] EntryRenderError),
#[error(transparent)]
GeneratedLink(#[from] GeneratedLinkError),
#[error(transparent)]
Witness(#[from] WitnessError),
#[error("entry directory must pass checks before changing generated links: {0}")]
InvalidEntryDirectory(PathBuf),
#[error("entry `{0}` is frozen; run `sirno melt {0}` before changing it")]
FrozenEntryProtected(EntryAddress),
#[error("glacier entry `{id}` is outside glacier domain `{domain}`")]
GlacierEntryOutsideDomain {
domain: EntryAtom,
id: EntryAddress,
},
#[error("glacier entry `{0}` must carry frozen reason `managed`")]
GlacierEntryNotManaged(EntryAddress),
#[error("glacier contains unmanaged path: {0}")]
UnmanagedGlacierPath(PathBuf),
#[error("entry `{0}` has no source file path")]
MissingEntryFilePath(EntryAddress),
#[error("failed to create entry file {path}")]
CreateFile {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write entry file {path}")]
WriteFile {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to rename entry file {source_path} to {destination_path}")]
RenameFile {
source_path: PathBuf,
destination_path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("entry `{owner}` has duplicate artifact `{path}`")]
DuplicateArtifact {
owner: EntryAddress,
path: EntryArtifactPath,
},
#[error("artifact source is not a file: {0}")]
ArtifactSourceNotFile(PathBuf),
#[error("entry `{owner}` has no artifact `{path}`")]
ArtifactNotFound {
owner: EntryAddress,
path: EntryArtifactPath,
},
#[error("entry `{owner}` already has artifact `{path}`")]
ArtifactAlreadyExists {
owner: EntryAddress,
path: EntryArtifactPath,
},
#[error("entry `{owner}` artifact rename source and destination are both `{path}`")]
ArtifactRenameSamePath {
owner: EntryAddress,
path: EntryArtifactPath,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
EntryMetadata, RepoMember, StructuralFieldSettings, WitnessCheckSettings, WitnessSettings,
};
const FIELD_KIND: &str = "kind";
const FIELD_AREA: &str = "area";
const FIELD_PARENT: &str = "parent";
fn write_entry(root: &Path, name: &str, body: &str) {
let path = root.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
fn write_structural_field_entries(root: &Path, fields: &[&str]) {
for field in fields {
write_entry(
root,
&format!("{field}.md"),
&format!(
"\
---
name: {field}
desc: A structural field.
---
Body.
"
),
);
}
}
fn entry_directory(root: impl Into<PathBuf>) -> EntryDirectory {
EntryDirectory::new(root)
}
fn witness_settings(root: &Path) -> EntryDirectoryCheckSettings {
EntryDirectoryCheckSettings {
witness: Some(WitnessCheckSettings::new(
root,
[RepoMember::new("src").unwrap()],
WitnessSettings::standard(),
)),
..EntryDirectoryCheckSettings::default()
}
}
fn structural_settings(
fields: impl IntoIterator<Item = (&'static str, StructuralFieldSettings)>,
) -> StructuralSettings {
StructuralSettings::from_fields(fields)
}
fn all_test_fields_linked() -> StructuralSettings {
structural_settings([
(FIELD_KIND, render_settings(true, true, false)),
(FIELD_AREA, render_settings(true, true, false)),
(FIELD_PARENT, render_settings(true, true, false)),
])
}
fn render_settings(to: bool, from: bool, clique: bool) -> StructuralFieldSettings {
StructuralFieldSettings::render_only(to, from, clique)
}
fn witness_begin(id: &str) -> String {
format!("{}{}{}{}", "// sirno", ":witness:", id, ":begin")
}
fn witness_end(id: &str) -> String {
format!("{}{}{}{}", "// sirno", ":witness:", id, ":end")
}
fn witness_block(id: &str) -> String {
let opening = witness_begin(id);
let closing = witness_end(id);
format!("{opening}\nbody\n{closing}\n")
}
#[test]
fn replaces_only_managed_glacier_entries() {
let temp = tempfile::tempdir().unwrap();
let directory = entry_directory(temp.path());
let domain = EntryAtom::new("core").unwrap();
let mut metadata = EntryMetadata::new("Design", "Managed upstream entry.").unwrap();
metadata.meta.frozen = Some(FrozenMarker::managed());
let entry = Entry::new(EntryAddress::new("core.design").unwrap(), metadata, "Body.\n");
let artifact = EntryArtifact::new(
EntryAddress::new("core.design").unwrap(),
EntryArtifactPath::new(Path::new("logo.bin")).unwrap(),
b"logo".to_vec(),
);
let report = directory
.replace_glacier(
&domain,
&[entry],
&[artifact],
&EntryDirectoryCheckSettings::default(),
)
.unwrap();
assert!(report.changed_paths().iter().any(|path| path.ends_with("core/design.md")));
assert!(temp.path().join("core/design.md").exists());
assert!(temp.path().join(".artifacts/core.design/logo.bin").exists());
write_entry(
temp.path(),
"core/local.md",
"\
---
name: Local
desc: Unmanaged local entry.
---
Body.
",
);
let error = directory
.replace_glacier(&domain, &[], &[], &EntryDirectoryCheckSettings::default())
.unwrap_err();
assert!(
matches!(error, EntryDirectoryError::UnmanagedGlacierPath(path) if path.ends_with("core/local.md"))
);
}
#[test]
fn melt_preserves_managed_frozen_reason() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"core/design.md",
"\
---
name: Design
desc: Managed upstream entry.
meta:
frozen:
- reviewed
- managed
---
Body.
",
);
let directory = entry_directory(temp.path());
directory.melt_entry(&EntryAddress::new("core.design").unwrap()).unwrap();
let source = fs::read_to_string(temp.path().join("core/design.md")).unwrap();
assert!(!source.contains("reviewed"));
assert!(source.contains(" - managed"));
let entry = directory.read_entry(&EntryAddress::new("core.design").unwrap()).unwrap();
assert!(entry.metadata.meta.frozen.as_ref().is_some_and(|marker| marker.is_managed()));
}
#[test]
fn checks_clean_markdown_entry_directory() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"meta.md",
"\
---
name: Meta
desc: A metadata entry.
---
Body.
",
);
write_entry(
temp.path(),
"concept.md",
"\
---
name: Concept
desc: A named idea.
---
Body.
",
);
let report = entry_directory(temp.path()).check(CheckMode::Review).unwrap();
assert!(report.is_clean());
assert_eq!(report.entries().len(), 2);
assert!(report.entry_file_path(&EntryAddress::new("concept").unwrap()).is_some());
}
#[test]
fn check_loads_nested_entry_addresses() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"core/design.md",
"\
---
name: Design
desc: Core design.
---
Body.
",
);
let report = entry_directory(temp.path()).check(CheckMode::Review).unwrap();
assert!(report.is_clean());
assert_eq!(report.entries()[0].id.as_str(), "core.design");
assert_eq!(
report.entry_file_path(&EntryAddress::new("core.design").unwrap()).unwrap(),
temp.path().join("core/design.md")
);
}
#[test]
fn check_rejects_dotted_markdown_filename() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"core.design.md",
"\
---
name: Design
desc: Core design.
---
Body.
",
);
let report = entry_directory(temp.path()).check(CheckMode::Review).unwrap();
assert!(report.has_errors());
assert!(report.file_diagnostics()[0].message.contains("filename must not contain dots"));
assert!(report.entries().is_empty());
}
#[test]
fn check_treats_unknown_leading_dot_root_as_reserved_builtin() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
".custom/design.md",
"\
---
name: Design
desc: Core design.
---
Body.
",
);
let report = entry_directory(temp.path()).check(CheckMode::Review).unwrap();
assert!(report.has_errors());
assert!(report.file_diagnostics()[0].message.contains("reserved built-in path"));
assert!(report.entries().is_empty());
}
#[test]
fn check_loads_entry_artifacts_from_reserved_directory() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"concept.md",
"\
---
name: Concept
desc: A named idea.
---
Body.
",
);
let artifact_dir = temp.path().join(ARTIFACT_DIRECTORY_NAME).join("concept").join("images");
fs::create_dir_all(&artifact_dir).unwrap();
fs::write(artifact_dir.join("logo.bin"), [0, 1, 2, 3]).unwrap();
let report = entry_directory(temp.path()).check(CheckMode::Review).unwrap();
assert!(report.is_clean());
assert_eq!(report.artifacts().len(), 1);
assert_eq!(report.artifacts()[0].owner, EntryAddress::new("concept").unwrap());
assert_eq!(report.artifacts()[0].path.as_str(), "images/logo.bin");
assert_eq!(report.artifacts()[0].content, vec![0, 1, 2, 3]);
}
#[test]
fn check_loads_dotted_entry_artifacts_from_reserved_directory() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"core/design.md",
"\
---
name: Design
desc: Core design.
---
Body.
",
);
let artifact_dir =
temp.path().join(ARTIFACT_DIRECTORY_NAME).join("core.design").join("images");
fs::create_dir_all(&artifact_dir).unwrap();
fs::write(artifact_dir.join("logo.bin"), [0, 1, 2, 3]).unwrap();
let report = entry_directory(temp.path()).check(CheckMode::Review).unwrap();
assert!(report.is_clean());
assert_eq!(report.artifacts()[0].owner, EntryAddress::new("core.design").unwrap());
assert_eq!(report.artifacts()[0].path.as_str(), "images/logo.bin");
}
#[test]
fn artifact_operations_accept_dotted_entry_addresses() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"core/design.md",
"\
---
name: Design
desc: Core design.
---
Body.
",
);
let source = temp.path().join("logo.bin");
fs::write(&source, [0, 1, 2, 3]).unwrap();
let directory = entry_directory(temp.path());
let id = EntryAddress::new("core.design").unwrap();
let added = directory
.add_entry_artifact(&id, &source, &EntryArtifactPath::new("images/logo.bin").unwrap())
.unwrap();
let renamed = directory
.rename_entry_artifact(
&id,
&EntryArtifactPath::new("images/logo.bin").unwrap(),
&EntryArtifactPath::new("assets/logo.bin").unwrap(),
)
.unwrap();
let artifacts = directory.read_entry_artifacts(&id).unwrap();
let removed = directory
.remove_entry_artifact(&id, &EntryArtifactPath::new("assets/logo.bin").unwrap())
.unwrap();
assert_eq!(added, temp.path().join(".artifacts/core.design/images/logo.bin"));
assert_eq!(renamed, temp.path().join(".artifacts/core.design/assets/logo.bin"));
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0].owner, id);
assert_eq!(artifacts[0].path.as_str(), "assets/logo.bin");
assert_eq!(artifacts[0].content, vec![0, 1, 2, 3]);
assert_eq!(removed, temp.path().join(".artifacts/core.design/assets/logo.bin"));
assert!(!temp.path().join(".artifacts/core.design").exists());
}
#[test]
fn artifact_operations_protect_frozen_dotted_entry_addresses() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"core/design.md",
"\
---
name: Design
desc: Core design.
---
Body.
",
);
let source = temp.path().join("logo.bin");
fs::write(&source, [0, 1, 2, 3]).unwrap();
let directory = entry_directory(temp.path());
let id = EntryAddress::new("core.design").unwrap();
directory.freeze_entry(&id).unwrap();
let error = directory
.add_entry_artifact(&id, &source, &EntryArtifactPath::new("images/logo.bin").unwrap())
.unwrap_err();
assert!(matches!(error, EntryDirectoryError::FrozenEntryProtected(path) if path == id));
directory.clear_local_protection(&EntryDirectoryCheckSettings::default(), false).unwrap();
}
#[test]
fn check_reports_artifacts_for_missing_entry() {
let temp = tempfile::tempdir().unwrap();
fs::create_dir_all(temp.path().join(ARTIFACT_DIRECTORY_NAME).join("ghost")).unwrap();
let report = entry_directory(temp.path()).check(CheckMode::Review).unwrap();
assert!(report.has_errors());
assert!(report.file_diagnostics()[0].message.contains("missing entry `ghost`"));
}
#[test]
fn entry_exists_checks_entry_file_presence() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"concept.md",
"\
---
name: Concept
desc: A named idea.
---
Body.
",
);
assert!(
entry_directory(temp.path())
.entry_exists(&EntryAddress::new("concept").unwrap())
.unwrap()
);
assert!(
!entry_directory(temp.path())
.entry_exists(&EntryAddress::new("missing").unwrap())
.unwrap()
);
}
#[test]
fn reports_parse_error_with_file_path() {
let temp = tempfile::tempdir().unwrap();
write_entry(temp.path(), "bad.md", "no frontmatter\n");
let report = entry_directory(temp.path()).check(CheckMode::Review).unwrap();
assert!(report.has_errors());
assert_eq!(report.file_diagnostics().len(), 1);
assert_eq!(report.file_diagnostics()[0].path, temp.path().join("bad.md"));
assert!(report.file_diagnostics()[0].message.contains("failed to parse entry"));
}
#[test]
fn reports_mixed_line_endings_as_warning() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"meta.md",
"---\r\nname: Meta\ndesc: A metadata entry.\r\n---\r\n\r\nBody.\n",
);
let report = entry_directory(temp.path()).check(CheckMode::Review).unwrap();
assert!(!report.is_clean());
assert!(!report.has_errors());
assert_eq!(report.entries().len(), 1);
assert_eq!(report.file_diagnostics()[0].severity, CheckSeverity::Warning);
assert!(report.file_diagnostics()[0].message.contains("mixed LF and CRLF"));
}
#[test]
fn reports_non_markdown_file_as_review_error() {
let temp = tempfile::tempdir().unwrap();
fs::write(temp.path().join("note.txt"), "text").unwrap();
let report = entry_directory(temp.path()).check(CheckMode::Review).unwrap();
assert_eq!(report.file_diagnostics()[0].severity, CheckSeverity::Error);
assert!(report.has_errors());
}
#[test]
fn reports_non_markdown_file_as_edit_warning() {
let temp = tempfile::tempdir().unwrap();
fs::write(temp.path().join("note.txt"), "text").unwrap();
let report = entry_directory(temp.path()).check(CheckMode::Edit).unwrap();
assert_eq!(report.file_diagnostics()[0].severity, CheckSeverity::Warning);
assert!(!report.has_errors());
}
#[test]
fn ignores_configured_lake_paths() {
let temp = tempfile::tempdir().unwrap();
fs::create_dir(temp.path().join(".obsidian")).unwrap();
fs::write(temp.path().join("note.txt"), "text").unwrap();
write_entry(
temp.path(),
"meta.md",
"\
---
name: Meta
desc: A metadata entry.
---
Body.
",
);
let report = entry_directory(temp.path())
.check_with_settings(
CheckMode::Review,
&EntryDirectoryCheckSettings {
ignore: vec![PathBuf::from(".obsidian"), PathBuf::from("note.txt")],
..EntryDirectoryCheckSettings::default()
},
)
.unwrap();
assert!(report.is_clean());
assert_eq!(report.entries().len(), 1);
}
#[test]
fn reports_structural_diagnostics_from_loaded_entries() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"concept.md",
"\
---
name: Concept
desc: A named idea.
kind:
- meta
---
",
);
write_structural_field_entries(temp.path(), &[FIELD_KIND]);
let report = entry_directory(temp.path())
.check_with_settings(
CheckMode::Review,
&EntryDirectoryCheckSettings {
structural: structural_settings([(
FIELD_KIND,
StructuralFieldSettings::default(),
)]),
..EntryDirectoryCheckSettings::default()
},
)
.unwrap();
assert!(report.has_errors());
assert_eq!(report.structural_report().diagnostics().len(), 1);
}
#[test]
fn check_can_skip_structural_inhabitance() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"concept.md",
"\
---
name: Concept
desc: A named idea.
---
Body.
",
);
let report = entry_directory(temp.path())
.check_with_settings(
CheckMode::Review,
&EntryDirectoryCheckSettings {
structural_inhabitance: false,
structural: structural_settings([(
FIELD_KIND,
StructuralFieldSettings::default(),
)]),
..EntryDirectoryCheckSettings::default()
},
)
.unwrap();
assert!(report.is_clean());
}
#[test]
fn check_accepts_witness_block_found_by_mosaika() {
let temp = tempfile::tempdir().unwrap();
let docs = temp.path().join("docs");
let src = temp.path().join("src");
fs::create_dir_all(&docs).unwrap();
fs::create_dir_all(&src).unwrap();
write_entry(
&docs,
"witnessed.md",
"\
---
name: Witnessed
desc: A witnessed entry.
---
Body.
",
);
fs::write(src.join("lib.rs"), witness_block("witnessed")).unwrap();
let report = entry_directory(&docs)
.check_with_settings(CheckMode::Review, &witness_settings(temp.path()))
.unwrap();
assert!(report.is_clean());
}
#[test]
fn check_reports_witness_block_for_missing_entry() {
let temp = tempfile::tempdir().unwrap();
let docs = temp.path().join("docs");
let src = temp.path().join("src");
fs::create_dir_all(&docs).unwrap();
fs::create_dir_all(&src).unwrap();
write_entry(
&docs,
"concept.md",
"\
---
name: Concept
desc: A concept.
---
Body.
",
);
fs::write(src.join("lib.rs"), witness_block("ghost-entry")).unwrap();
let report = entry_directory(&docs)
.check_with_settings(CheckMode::Review, &witness_settings(temp.path()))
.unwrap();
assert!(report.has_errors());
assert!(report.file_diagnostics()[0].message.contains("missing entry `ghost-entry`"));
}
#[test]
fn check_reports_orphan_witness_begin_delimiter() {
let temp = tempfile::tempdir().unwrap();
let docs = temp.path().join("docs");
let src = temp.path().join("src");
fs::create_dir_all(&docs).unwrap();
fs::create_dir_all(&src).unwrap();
write_entry(
&docs,
"concept.md",
"\
---
name: Concept
desc: A concept.
---
Body.
",
);
fs::write(src.join("lib.rs"), format!("{}\nbody\n", witness_begin("concept"))).unwrap();
let report = entry_directory(&docs)
.check_with_settings(CheckMode::Review, &witness_settings(temp.path()))
.unwrap();
assert!(report.has_errors());
assert_eq!(report.file_diagnostics()[0].severity, CheckSeverity::Error);
assert!(report.file_diagnostics()[0].message.contains("opening delimiter"));
assert!(report.file_diagnostics()[0].message.contains("no closing delimiter"));
}
#[test]
fn check_reports_orphan_witness_end_delimiter_as_edit_warning() {
let temp = tempfile::tempdir().unwrap();
let docs = temp.path().join("docs");
let src = temp.path().join("src");
fs::create_dir_all(&docs).unwrap();
fs::create_dir_all(&src).unwrap();
write_entry(
&docs,
"concept.md",
"\
---
name: Concept
desc: A concept.
---
Body.
",
);
fs::write(src.join("lib.rs"), format!("body\n{}\n", witness_end("concept"))).unwrap();
let report = entry_directory(&docs)
.check_with_settings(CheckMode::Edit, &witness_settings(temp.path()))
.unwrap();
assert!(!report.has_errors());
assert_eq!(report.file_diagnostics()[0].severity, CheckSeverity::Warning);
assert!(report.file_diagnostics()[0].message.contains("closing delimiter"));
assert!(report.file_diagnostics()[0].message.contains("no opening delimiter"));
}
#[test]
fn missing_directory_is_a_load_error() {
let temp = tempfile::tempdir().unwrap();
let missing = temp.path().join("missing");
let error = entry_directory(&missing).check(CheckMode::Review).unwrap_err();
assert!(matches!(error, EntryDirectoryError::MissingDirectory(_)));
}
#[test]
fn initializes_seed_entry_files() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
let paths = entry_directory(&root).init().unwrap();
let report = entry_directory(&root).check(CheckMode::Review).unwrap();
assert_eq!(paths.len(), 4);
assert!(root.join("concept.md").exists());
assert!(report.is_clean());
}
#[test]
fn init_refuses_to_overwrite_entry_files() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
entry_directory(&root).init().unwrap();
let error = entry_directory(&root).init().unwrap_err();
assert!(matches!(error, EntryDirectoryError::CreateFile { .. }));
}
#[test]
fn create_entry_file_writes_one_entry() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
let mut metadata = EntryMetadata::new("Local Idea", "A local design idea.").unwrap();
metadata.push_structural_target(FIELD_KIND, EntryAddress::new("meta").unwrap());
let entry = Entry::new(EntryAddress::new("local-idea").unwrap(), metadata, "");
let path = entry_directory(&root).create_entry(&entry).unwrap();
let source = fs::read_to_string(&path).unwrap();
assert_eq!(path, root.join("local-idea.md"));
assert!(source.contains("name: Local Idea\n"));
assert!(source.contains("kind:\n - meta\n"));
}
#[test]
fn create_entry_file_refuses_to_overwrite() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
let metadata = EntryMetadata::new("Local Idea", "A local design idea.").unwrap();
let entry = Entry::new(EntryAddress::new("local-idea").unwrap(), metadata, "");
entry_directory(&root).create_entry(&entry).unwrap();
let error = entry_directory(&root).create_entry(&entry).unwrap_err();
assert!(matches!(error, EntryDirectoryError::CreateFile { .. }));
}
#[test]
fn rename_entry_updates_file_structural_targets_and_generated_links() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
fs::create_dir(&root).unwrap();
write_entry(
&root,
"concept.md",
"\
---
name: Concept
desc: A named idea.
---
Body.
",
);
write_structural_field_entries(&root, &[FIELD_KIND, FIELD_AREA]);
write_entry(
&root,
"old-entry.md",
"\
---
name: Source
desc: Source entry.
kind:
- concept
---
Body.
",
);
write_entry(
&root,
"reader.md",
"\
---
name: Reader
desc: Reader entry.
area:
- old-entry
---
Body.
",
);
let settings = EntryDirectoryCheckSettings {
structural: structural_settings([
(FIELD_KIND, StructuralFieldSettings::default()),
(FIELD_AREA, render_settings(true, true, false)),
]),
..EntryDirectoryCheckSettings::default()
};
let directory = entry_directory(&root);
directory.generate_links(&settings.structural).unwrap();
let artifact_dir = root.join(ARTIFACT_DIRECTORY_NAME).join("old-entry");
fs::create_dir_all(&artifact_dir).unwrap();
fs::write(artifact_dir.join("note.txt"), "artifact").unwrap();
let report = directory
.rename_entry(
&EntryAddress::new("old-entry").unwrap(),
&EntryAddress::new("new-entry").unwrap(),
&settings,
)
.unwrap();
let checked = directory.check_with_settings(CheckMode::Review, &settings).unwrap();
let reader_source = fs::read_to_string(root.join("reader.md")).unwrap();
let renamed_source = fs::read_to_string(root.join("new-entry.md")).unwrap();
assert_eq!(report.old_id(), &EntryAddress::new("old-entry").unwrap());
assert_eq!(report.new_id(), &EntryAddress::new("new-entry").unwrap());
assert!(report.changed_paths().contains(&root.join("new-entry.md")));
assert!(!root.join("old-entry.md").exists());
assert!(root.join("new-entry.md").exists());
assert!(!root.join(ARTIFACT_DIRECTORY_NAME).join("old-entry").exists());
assert_eq!(
fs::read_to_string(
root.join(ARTIFACT_DIRECTORY_NAME).join("new-entry").join("note.txt")
)
.unwrap(),
"artifact"
);
assert!(reader_source.contains("area:\n - new-entry\n"));
assert!(!reader_source.contains("old-entry"));
assert!(renamed_source.contains("[reader](reader.md)"));
assert!(checked.is_clean());
}
#[test]
fn rename_entry_updates_configured_structural_field_names() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
fs::create_dir(&root).unwrap();
write_entry(
&root,
"concept.md",
"\
---
name: Concept
desc: A named idea.
---
Body.
",
);
write_entry(
&root,
"refines.md",
"\
---
name: Refines
desc: A structural field.
---
Body.
",
);
write_entry(
&root,
"reader.md",
"\
---
name: Reader
desc: Reader entry.
refines:
- concept
---
Body.
",
);
let settings = EntryDirectoryCheckSettings {
structural: structural_settings([("refines", render_settings(true, true, false))]),
..EntryDirectoryCheckSettings::default()
};
let directory = entry_directory(&root);
directory.generate_links(&settings.structural).unwrap();
directory
.rename_entry(
&EntryAddress::new("refines").unwrap(),
&EntryAddress::new("prerequisite").unwrap(),
&settings,
)
.unwrap();
let reader_source = fs::read_to_string(root.join("reader.md")).unwrap();
let concept_source = fs::read_to_string(root.join("concept.md")).unwrap();
assert!(!root.join("refines.md").exists());
assert!(root.join("prerequisite.md").exists());
assert!(reader_source.contains("prerequisite:\n - concept\n"));
assert!(!reader_source.contains("refines:"));
assert!(concept_source.contains("- prerequisite (from):\n - [reader](reader.md)"));
}
#[test]
fn rename_entry_refuses_existing_destination() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"old-entry.md",
"\
---
name: Old
desc: Old entry.
---
Body.
",
);
write_entry(
temp.path(),
"new-entry.md",
"\
---
name: New
desc: New entry.
---
Body.
",
);
let error = entry_directory(temp.path())
.rename_entry(
&EntryAddress::new("old-entry").unwrap(),
&EntryAddress::new("new-entry").unwrap(),
&EntryDirectoryCheckSettings::default(),
)
.unwrap_err();
assert!(matches!(error, EntryDirectoryError::EntryAlreadyExists { .. }));
}
#[test]
fn rename_entry_leaves_unreferenced_entries_untouched() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"old-entry.md",
"\
---
name: Old
desc: Old entry.
---
Body.
",
);
let untouched = "\
---
desc: Untouched entry.
name: Untouched
---
Body.
";
write_entry(temp.path(), "untouched.md", untouched);
entry_directory(temp.path())
.rename_entry(
&EntryAddress::new("old-entry").unwrap(),
&EntryAddress::new("new-entry").unwrap(),
&EntryDirectoryCheckSettings::default(),
)
.unwrap();
assert_eq!(fs::read_to_string(temp.path().join("untouched.md")).unwrap(), untouched);
}
#[test]
fn freeze_entry_file_writes_marker_and_removes_write_permission() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"alpha.md",
"\
---
name: Alpha
desc: Alpha entry.
---
Body.
",
);
let path = entry_directory(temp.path())
.freeze_entry(&EntryAddress::new("alpha").unwrap())
.unwrap();
let source = fs::read_to_string(&path).unwrap();
assert!(source.contains("meta:\n frozen:\n - reviewed\n"));
assert_path_readonly(&path);
}
#[test]
fn set_writable_preserves_frozen_entry_permission() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"alpha.md",
"\
---
name: Alpha
desc: Alpha entry.
---
Body.
",
);
let settings = EntryDirectoryCheckSettings::default();
let directory = entry_directory(temp.path());
let path = directory.freeze_entry(&EntryAddress::new("alpha").unwrap()).unwrap();
directory.set_writable(&settings).unwrap();
assert_path_readonly(&path);
directory.melt_entry(&EntryAddress::new("alpha").unwrap()).unwrap();
}
#[test]
fn melt_entry_file_removes_marker_and_restores_write_permission() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"alpha.md",
"\
---
name: Alpha
desc: Alpha entry.
meta:
frozen:
- reviewed
---
Body.
",
);
let path = entry_directory(temp.path())
.freeze_entry(&EntryAddress::new("alpha").unwrap())
.unwrap();
entry_directory(temp.path()).melt_entry(&EntryAddress::new("alpha").unwrap()).unwrap();
let source = fs::read_to_string(&path).unwrap();
assert!(!source.contains("frozen:\n"));
assert_path_writable(&path);
}
#[test]
fn generate_links_refuses_to_change_frozen_entry() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"alpha.md",
"\
---
name: Alpha
desc: Alpha entry.
meta:
frozen:
- reviewed
kind:
- beta
---
Body.
",
);
write_entry(
temp.path(),
"beta.md",
"\
---
name: Beta
desc: Beta entry.
---
Body.
",
);
write_structural_field_entries(temp.path(), &[FIELD_KIND]);
let error = entry_directory(temp.path())
.generate_links(&structural_settings([(
FIELD_KIND,
render_settings(true, false, false),
)]))
.unwrap_err();
assert!(
matches!(error, EntryDirectoryError::FrozenEntryProtected(id) if id.as_str() == "alpha")
);
}
#[test]
fn replace_entry_directory_preserves_ignored_paths() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
fs::create_dir_all(root.join(".obsidian")).unwrap();
fs::write(root.join(".obsidian/state.json"), "{}").unwrap();
fs::write(root.join("old.md"), "---\nname: Old\ndesc: Old.\n---\n").unwrap();
let metadata = EntryMetadata::new("New", "New entry.").unwrap();
let entry = Entry::new(EntryAddress::new("new").unwrap(), metadata, "Body.\n");
entry_directory(&root)
.write(
std::slice::from_ref(&entry),
EntryDirectoryWritePolicy::ReplaceDirectory {
ignore: vec![PathBuf::from(".obsidian")],
},
)
.unwrap();
assert!(!root.join("old.md").exists());
assert!(root.join("new.md").exists());
assert!(root.join(".obsidian/state.json").exists());
}
#[test]
fn replace_entry_directory_replaces_readonly_artifact_tree() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
fs::create_dir_all(root.join(ARTIFACT_DIRECTORY_NAME).join("old")).unwrap();
fs::write(root.join("old.md"), "---\nname: Old\ndesc: Old.\n---\n").unwrap();
fs::write(root.join(ARTIFACT_DIRECTORY_NAME).join("old").join("note.txt"), "old").unwrap();
let directory = entry_directory(&root);
directory.set_readonly(&EntryDirectoryCheckSettings::default()).unwrap();
let metadata = EntryMetadata::new("New", "New entry.").unwrap();
let entry = Entry::new(EntryAddress::new("new").unwrap(), metadata, "Body.\n");
let artifact = EntryArtifact::new(
entry.id.clone(),
EntryArtifactPath::new("note.txt").unwrap(),
b"new",
);
directory
.write_with_artifacts(
std::slice::from_ref(&entry),
&[artifact],
EntryDirectoryWritePolicy::ReplaceDirectory { ignore: Vec::new() },
)
.unwrap();
assert!(!root.join("old.md").exists());
assert!(!root.join(ARTIFACT_DIRECTORY_NAME).join("old").exists());
assert_eq!(
fs::read(root.join(ARTIFACT_DIRECTORY_NAME).join("new").join("note.txt")).unwrap(),
b"new"
);
}
#[test]
fn replace_entry_directory_rejects_stray_markdown() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
fs::create_dir_all(&root).unwrap();
fs::write(root.join("2026-05-12.md"), "").unwrap();
let metadata = EntryMetadata::new("New", "New entry.").unwrap();
let entry = Entry::new(EntryAddress::new("new").unwrap(), metadata, "Body.\n");
let error = entry_directory(&root)
.write(&[entry], EntryDirectoryWritePolicy::ReplaceDirectory { ignore: Vec::new() })
.unwrap_err();
assert!(matches!(error, EntryDirectoryError::CheckoutConflict(_)));
assert!(root.join("2026-05-12.md").exists());
}
#[test]
fn readonly_entry_directory_can_be_made_writable_again() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
entry_directory(&root).init().unwrap();
let settings = EntryDirectoryCheckSettings::default();
let entry_path = root.join("concept.md");
entry_directory(&root).set_readonly(&settings).unwrap();
let entry_was_immutable = FrozenPath::new(&entry_path).is_frozen().unwrap_or(false);
let root_was_immutable = FrozenPath::new(&root).is_frozen().unwrap_or(false);
assert!(fs::metadata(&entry_path).unwrap().permissions().readonly());
assert!(fs::metadata(&root).unwrap().permissions().readonly());
entry_directory(&root).set_writable(&settings).unwrap();
assert!(!fs::metadata(&entry_path).unwrap().permissions().readonly());
assert!(!fs::metadata(&root).unwrap().permissions().readonly());
if entry_was_immutable {
assert!(!FrozenPath::new(&entry_path).is_frozen().unwrap());
}
if root_was_immutable {
assert!(!FrozenPath::new(&root).is_frozen().unwrap());
}
}
#[test]
fn clear_local_protection_keeps_frozen_marker() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"alpha.md",
"\
---
name: Alpha
desc: Alpha entry.
---
Body.
",
);
let directory = entry_directory(temp.path());
let path = directory.freeze_entry(&EntryAddress::new("alpha").unwrap()).unwrap();
let report = directory
.clear_local_protection(&EntryDirectoryCheckSettings::default(), false)
.unwrap();
let source = fs::read_to_string(&path).unwrap();
assert!(source.contains("meta:\n frozen:\n - reviewed\n"));
assert_path_writable(&path);
assert!(report.paths().contains(&path));
}
#[test]
fn fix_local_protection_reapplies_frozen_entry_permissions() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"alpha.md",
"\
---
name: Alpha
desc: Alpha entry.
---
Body.
",
);
let directory = entry_directory(temp.path());
let id = EntryAddress::new("alpha").unwrap();
let path = directory.freeze_entry(&id).unwrap();
directory.clear_local_protection(&EntryDirectoryCheckSettings::default(), false).unwrap();
let report = directory
.fix_local_protection(&EntryDirectoryCheckSettings::default(), false, false)
.unwrap();
assert_path_readonly(&path);
assert!(report.paths().contains(&path));
directory.clear_local_protection(&EntryDirectoryCheckSettings::default(), false).unwrap();
}
#[test]
fn fix_local_protection_can_repair_readonly_checkout_state() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
entry_directory(&root).init().unwrap();
let settings = EntryDirectoryCheckSettings::default();
let entry_path = root.join("concept.md");
let report = entry_directory(&root).fix_local_protection(&settings, true, false).unwrap();
assert_path_readonly(&root);
assert_path_readonly(&entry_path);
assert!(report.paths().contains(&root));
assert!(report.paths().contains(&entry_path));
entry_directory(&root).clear_local_protection(&settings, false).unwrap();
}
#[test]
fn readonly_checkout_warning_is_visible_body_quote() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
let metadata = EntryMetadata::new("New", "New entry.").unwrap();
let entry = Entry::new(EntryAddress::new("new").unwrap(), metadata, "Body.\n");
let paths = entry_directory(&root)
.write(std::slice::from_ref(&entry), EntryDirectoryWritePolicy::EmptyDirectory)
.unwrap();
entry_directory(&root).add_readonly_checkout_warnings(&paths).unwrap();
let source = fs::read_to_string(root.join("new.md")).unwrap();
let checked = entry_directory(&root).check(CheckMode::Review).unwrap();
assert!(source.contains(
"\n---\n\n> This file is a read-only Sirno Frost checkout.\n\
> Do not edit it by hand.\n\nBody.\n"
));
assert_eq!(checked.entries()[0].metadata, entry.metadata);
assert!(checked.entries()[0].body.starts_with(READONLY_CHECKOUT_WARNING));
assert!(checked.entries()[0].body.ends_with("Body.\n"));
}
#[test]
fn gen_link_writes_generated_footers() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
entry_directory(&root).init().unwrap();
write_structural_field_entries(&root, &[FIELD_KIND, FIELD_AREA, FIELD_PARENT]);
let settings = all_test_fields_linked();
let report = entry_directory(&root).generate_links(&settings).unwrap();
let concept = fs::read_to_string(root.join("concept.md")).unwrap();
assert_eq!(report.entry_count(), 7);
assert_eq!(report.changed_paths().len(), 7);
assert!(concept.contains(crate::render::BEGIN_LINKS_GUARD));
assert!(concept.contains("\n---\n\n> **Sirno generated links begin."));
assert!(concept.contains("- kind (to): (none)"));
assert!(!concept.contains("## Sirno Links"));
assert!(!concept.contains("kind: [meta](meta.md)"));
}
#[test]
fn gen_link_expands_cliques_with_lake_context() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"core.md",
"\
---
name: Core
desc: A review neighborhood.
---
Body.
",
);
write_structural_field_entries(temp.path(), &[FIELD_AREA]);
write_entry(
temp.path(),
"left.md",
"\
---
name: Left
desc: A neighborhood member.
area:
- core
---
Body.
",
);
write_entry(
temp.path(),
"right.md",
"\
---
name: Right
desc: A neighborhood member.
area:
- core
---
Body.
",
);
let settings = structural_settings([(FIELD_AREA, render_settings(true, true, true))]);
entry_directory(temp.path()).generate_links(&settings).unwrap();
let core = fs::read_to_string(temp.path().join("core.md")).unwrap();
let left = fs::read_to_string(temp.path().join("left.md")).unwrap();
assert!(core.contains("- [left](left.md)"));
assert!(core.contains("- [right](right.md)"));
assert!(!core.contains("[core](core.md)"));
assert!(left.contains("- [core](core.md)"));
assert!(left.contains("- [right](right.md)"));
assert!(!left.contains("[left](left.md)"));
}
#[test]
fn gen_link_is_idempotent() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
entry_directory(&root).init().unwrap();
let settings = StructuralSettings::default();
entry_directory(&root).generate_links(&settings).unwrap();
let report = entry_directory(&root).generate_links(&settings).unwrap();
assert!(report.changed_paths().is_empty());
}
#[test]
fn check_gen_link_reports_changes_without_writing() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
entry_directory(&root).init().unwrap();
let settings = StructuralSettings::default();
let report = entry_directory(&root).check_generated_links(&settings).unwrap();
let concept = fs::read_to_string(root.join("concept.md")).unwrap();
assert_eq!(report.entry_count(), 4);
assert_eq!(report.changed_paths().len(), 4);
assert!(!concept.contains(crate::render::BEGIN_LINKS_GUARD));
entry_directory(&root).generate_links(&settings).unwrap();
let report = entry_directory(&root).check_generated_links(&settings).unwrap();
assert!(report.changed_paths().is_empty());
}
#[test]
fn delete_gen_link_removes_generated_footers() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
entry_directory(&root).init().unwrap();
entry_directory(&root).generate_links(&StructuralSettings::default()).unwrap();
let report = entry_directory(&root).delete_generated_links().unwrap();
let concept = fs::read_to_string(root.join("concept.md")).unwrap();
assert_eq!(report.entry_count(), 4);
assert_eq!(report.changed_paths().len(), 4);
assert!(!concept.contains(crate::render::BEGIN_LINKS_GUARD));
}
#[test]
fn delete_gen_link_is_idempotent() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
entry_directory(&root).init().unwrap();
let report = entry_directory(&root).delete_generated_links().unwrap();
assert_eq!(report.entry_count(), 4);
assert!(report.changed_paths().is_empty());
}
#[test]
fn check_reports_stale_generated_links_as_review_error() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
entry_directory(&root).init().unwrap();
write_structural_field_entries(&root, &[FIELD_KIND, FIELD_AREA, FIELD_PARENT]);
let old_settings = all_test_fields_linked();
entry_directory(&root).generate_links(&old_settings).unwrap();
let report = entry_directory(&root)
.check_with_settings(
CheckMode::Review,
&EntryDirectoryCheckSettings {
render: true,
structural: StructuralSettings::default(),
..EntryDirectoryCheckSettings::default()
},
)
.unwrap();
assert!(report.has_errors());
assert_eq!(report.file_diagnostics()[0].severity, CheckSeverity::Error);
assert!(report.file_diagnostics()[0].message.contains("generated links are stale"));
}
#[test]
fn check_reports_stale_generated_links_as_edit_warning() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
entry_directory(&root).init().unwrap();
write_structural_field_entries(&root, &[FIELD_KIND, FIELD_AREA, FIELD_PARENT]);
let old_settings = all_test_fields_linked();
entry_directory(&root).generate_links(&old_settings).unwrap();
let report = entry_directory(&root)
.check_with_settings(
CheckMode::Edit,
&EntryDirectoryCheckSettings {
render: true,
structural: StructuralSettings::default(),
..EntryDirectoryCheckSettings::default()
},
)
.unwrap();
assert!(!report.has_errors());
assert_eq!(report.file_diagnostics()[0].severity, CheckSeverity::Warning);
}
#[test]
fn check_can_skip_stale_generated_links() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("docs");
entry_directory(&root).init().unwrap();
write_structural_field_entries(&root, &[FIELD_KIND, FIELD_AREA, FIELD_PARENT]);
let old_settings = all_test_fields_linked();
entry_directory(&root).generate_links(&old_settings).unwrap();
let report = entry_directory(&root)
.check_with_settings(
CheckMode::Review,
&EntryDirectoryCheckSettings {
render: false,
structural: StructuralSettings::default(),
..EntryDirectoryCheckSettings::default()
},
)
.unwrap();
assert!(report.is_clean());
}
#[test]
fn check_reports_malformed_generated_link_boundaries() {
let temp = tempfile::tempdir().unwrap();
write_entry(
temp.path(),
"concept.md",
"\
---
name: Concept
desc: A named idea.
---
Body.
> **Sirno generated links begin. Do not edit this section.**
",
);
let report = entry_directory(temp.path()).check(CheckMode::Review).unwrap();
assert!(report.has_errors());
assert!(report.file_diagnostics()[0].message.contains("malformed generated links"));
}
#[test]
fn gen_link_refuses_dirty_entry_directory() {
let temp = tempfile::tempdir().unwrap();
write_entry(temp.path(), "bad.md", "no frontmatter\n");
let error = entry_directory(temp.path())
.generate_links(&StructuralSettings::default())
.unwrap_err();
assert!(matches!(error, EntryDirectoryError::InvalidEntryDirectory(_)));
}
fn assert_path_readonly(path: &Path) {
let permissions = fs::metadata(path).unwrap().permissions();
#[cfg(unix)]
assert_eq!(permissions.mode() & 0o222, 0);
#[cfg(not(unix))]
assert!(permissions.readonly());
}
fn assert_path_writable(path: &Path) {
let permissions = fs::metadata(path).unwrap().permissions();
#[cfg(unix)]
assert_ne!(permissions.mode() & 0o222, 0);
#[cfg(not(unix))]
assert!(!permissions.readonly());
}
}