use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use thiserror::Error;
use tracing::trace;
use crate::check::{CheckMode, CheckReport, CheckSeverity};
use crate::entry::{
Entry, EntryParseError, EntryRenderError, FrozenMarker, has_mixed_line_endings,
};
use crate::id::EntryId;
use crate::links::{GeneratedLinkBody, GeneratedLinkError, GeneratedLinkIndex, 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>,
paths_by_id: BTreeMap<EntryId, PathBuf>,
file_diagnostics: Vec<EntryFileDiagnostic>,
structural_report: CheckReport,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EntryDirectoryCheckSettings {
pub link: bool,
pub structural: StructuralSettings,
pub ignore: Vec<PathBuf>,
pub witness: Option<WitnessCheckSettings>,
}
impl Default for EntryDirectoryCheckSettings {
fn default() -> Self {
Self {
link: 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 file_diagnostics(&self) -> &[EntryFileDiagnostic] {
&self.file_diagnostics
}
pub fn structural_report(&self) -> &CheckReport {
&self.structural_report
}
pub fn entry_path(&self, id: &EntryId) -> Option<&Path> {
self.paths_by_id.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: EntryId,
new_id: EntryId,
changed_paths: Vec<PathBuf>,
}
impl EntryRenameReport {
pub fn old_id(&self) -> &EntryId {
&self.old_id
}
pub fn new_id(&self) -> &EntryId {
&self.new_id
}
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_exists(&self, id: &EntryId) -> 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 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(&loaded.entries, &settings.structural);
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,
paths_by_id: loaded.paths_by_id,
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: &EntryId) -> Result<PathBuf, EntryDirectoryError> {
self.set_entry_frozen(id, true)
}
pub fn melt_entry(&self, id: &EntryId) -> Result<PathBuf, EntryDirectoryError> {
self.set_entry_frozen(id, false)
}
pub fn rename_entry(
&self, old_id: &EntryId, new_id: &EntryId, 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.link = 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_path(old_id).is_none() {
return Err(EntryDirectoryError::EntryNotFound(old_id.clone()));
}
let new_path = self.entry_file_path(new_id);
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 entries = Vec::<(EntryId, 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 content_changed = entry.metadata.rename_structural_target(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 = GeneratedLinkIndex::from_entries(&indexed_entries);
let mut changed_paths = Vec::new();
for (original_id, mut entry, mut content_changed) in entries {
let source_path = checked
.entry_path(&original_id)
.ok_or_else(|| EntryDirectoryError::MissingEntryPath(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, &settings.structural);
let body = GeneratedLinkBody::new(&entry.body);
if body.is_stale(&footer)? {
entry.body = body.apply(&footer)?;
content_changed = true;
}
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.frozen.is_some() {
set_path_readonly(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.frozen.is_some() {
set_path_readonly(source_path)?;
}
changed_paths.push(source_path.to_path_buf());
}
}
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> {
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)?);
}
trace!("write_entry_directory end: entries={}", paths.len());
Ok(paths)
}
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 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, GenLinkOperation::Write)
}
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, GenLinkOperation::Check)
}
fn process_generated_links(
&self, settings: &StructuralSettings, ignore: impl IntoIterator<Item = PathBuf>,
operation: GenLinkOperation,
) -> Result<GenLinkDirectoryReport, EntryDirectoryError> {
trace!(
"gen_link_entry_directory begin: root={} operation={}",
self.root.display(),
operation.label()
);
let check_settings = EntryDirectoryCheckSettings {
link: false,
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 = GeneratedLinkIndex::from_entries(checked.entries());
for entry in checked.entries() {
let path = checked
.entry_path(&entry.id)
.ok_or_else(|| EntryDirectoryError::MissingEntryPath(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() {
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 {
link: false,
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_path(&entry.id)
.ok_or_else(|| EntryDirectoryError::MissingEntryPath(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 {
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);
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 set_entry_frozen(&self, id: &EntryId, 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)?;
entry.metadata.frozen = frozen.then_some(FrozenMarker::Present);
let rendered = entry.to_markdown()?;
if rendered != source {
set_path_writable(&path)?;
fs::write(&path, rendered)
.map_err(|source| EntryDirectoryError::WriteFile { path: path.clone(), source })?;
}
if frozen {
set_path_readonly(&path)?;
} else {
set_path_writable(&path)?;
}
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()));
}
set_path_writable(&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> {
for path in sorted_directory_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;
}
let file_type = fs::symlink_metadata(&path)?.file_type();
if file_type.is_file()
&& path.extension().and_then(|extension| extension.to_str()) == Some("md")
&& Self::is_managed_entry_file(&path)?
{
set_path_writable(&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) = EntryId::new(stem) else {
return Ok(false);
};
let source = fs::read_to_string(path)?;
Ok(Entry::from_markdown(id, &source).is_ok())
}
fn set_writability(
&self, settings: &EntryDirectoryCheckSettings, writable: bool,
) -> 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()));
}
if writable {
set_path_writable(&self.root)?;
}
self.set_child_writability(&self.root, settings, writable)?;
if !writable {
set_path_readonly(&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 {
set_path_writable(&path)?;
}
if file_type.is_dir() {
self.set_child_writability(&path, settings, writable)?;
}
if !writable {
set_path_readonly(&path)?;
}
}
Ok(())
}
fn entry_file_path(&self, id: &EntryId) -> PathBuf {
self.root.join(format!("{}.md", id.as_str()))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum GenLinkOperation {
Check,
Write,
}
impl GenLinkOperation {
fn label(self) -> &'static str {
match self {
| Self::Check => "check",
| Self::Write => "write",
}
}
fn writes(self) -> bool {
matches!(self, Self::Write)
}
}
#[derive(Debug)]
struct LoadedEntryDirectory {
entries: Vec<Entry>,
paths_by_id: BTreeMap<EntryId, 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_id = BTreeMap::<EntryId, PathBuf>::new();
let mut seen_ids = BTreeSet::<EntryId>::new();
let mut file_diagnostics = Vec::new();
for path in sorted_directory_paths(root)? {
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 file_type.is_dir() {
file_diagnostics.push(EntryFileDiagnostic::new(
non_entry_severity,
&path,
"entry directory contains unsupported subdirectory",
));
continue;
}
if !file_type.is_file() {
file_diagnostics.push(EntryFileDiagnostic::new(
non_entry_severity,
&path,
"entry directory contains unsupported filesystem item",
));
continue;
}
if path.extension().and_then(|extension| extension.to_str()) != Some("md") {
file_diagnostics.push(EntryFileDiagnostic::new(
non_entry_severity,
&path,
"entry directory contains non-Markdown file",
));
continue;
}
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
file_diagnostics.push(EntryFileDiagnostic::new(
CheckSeverity::Error,
&path,
"entry file stem must be valid UTF-8",
));
continue;
};
let id = match EntryId::new(stem) {
| Ok(id) => id,
| Err(source) => {
file_diagnostics.push(EntryFileDiagnostic::new(
CheckSeverity::Error,
&path,
format!("entry file stem is not a valid id: {source}"),
));
continue;
}
};
if seen_ids.contains(&id) {
let first_path = paths_by_id
.get(&id)
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<unknown>".to_owned());
file_diagnostics.push(EntryFileDiagnostic::new(
CheckSeverity::Error,
&path,
format!("entry id `{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_id.insert(id, path);
entries.push(entry);
}
entries.sort_by(|left, right| left.id.cmp(&right.id));
let mut loaded = Self { entries, paths_by_id, file_diagnostics };
loaded.add_generated_link_diagnostics(mode, settings)?;
loaded.add_witness_diagnostics(mode, settings)?;
Ok(loaded)
}
fn add_generated_link_diagnostics(
&mut self, mode: CheckMode, settings: &EntryDirectoryCheckSettings,
) -> Result<(), EntryDirectoryError> {
let index = GeneratedLinkIndex::from_entries(&self.entries);
for entry in &self.entries {
let path = self
.paths_by_id
.get(&entry.id)
.ok_or_else(|| EntryDirectoryError::MissingEntryPath(entry.id.clone()))?;
let body = GeneratedLinkBody::new(&entry.body);
match body.validate() {
| Ok(()) if settings.link => {
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 gen-link`",
));
}
}
| 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_id in index.entry_ids() {
if ids.contains(witness_id) {
continue;
}
for record in index.records_for(witness_id) {
self.file_diagnostics.push(EntryFileDiagnostic::new(
severity,
&record.path,
format!("repository witness block references missing entry `{witness_id}`"),
));
}
}
Ok(())
}
}
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 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 = EntryId::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 set_path_readonly(path: &Path) -> Result<(), EntryDirectoryError> {
set_path_writable_flag(path, false)
}
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 path 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(EntryId),
#[error("entry `{0}` does not exist")]
EntryNotFound(EntryId),
#[error("entry `{id}` already exists at {path}")]
EntryAlreadyExists {
id: EntryId,
path: PathBuf,
},
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("entry path {path} is not inside entry directory {root}")]
StripRoot {
path: PathBuf,
root: PathBuf,
#[source]
source: std::path::StripPrefixError,
},
#[error(transparent)]
EntryParse(#[from] EntryParseError),
#[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}` has no source file path")]
MissingEntryPath(EntryId),
#[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,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
EntryMetadata, RepoMember, StructuralFieldSettings, StructuralLinkSettings,
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) {
fs::write(root.join(name), body).unwrap();
}
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, StructuralLinkSettings)>,
) -> StructuralSettings {
StructuralSettings::from_fields(
fields.into_iter().map(|(field, link)| (field, StructuralFieldSettings::new(link))),
)
}
fn all_test_fields_linked() -> StructuralSettings {
structural_settings([
(FIELD_KIND, StructuralLinkSettings::enabled()),
(FIELD_AREA, StructuralLinkSettings::enabled()),
(FIELD_PARENT, StructuralLinkSettings::enabled()),
])
}
fn witness_block(id: &str) -> String {
let opening = format!("{}{}{}{}", "// sirno", ":witness:", id, ":begin");
let closing = format!("{}{}{}{}", "// sirno", ":witness:", id, ":end");
format!("{opening}\nbody\n{closing}\n")
}
#[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_path(&EntryId::new("concept").unwrap()).is_some());
}
#[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(&EntryId::new("concept").unwrap()).unwrap()
);
assert!(
!entry_directory(temp.path()).entry_exists(&EntryId::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
---
",
);
let report = entry_directory(temp.path())
.check_with_settings(
CheckMode::Review,
&EntryDirectoryCheckSettings {
structural: structural_settings([(
FIELD_KIND,
StructuralLinkSettings::disabled(),
)]),
..EntryDirectoryCheckSettings::default()
},
)
.unwrap();
assert!(report.has_errors());
assert_eq!(report.structural_report().diagnostics().len(), 1);
}
#[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 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, EntryId::new("meta").unwrap());
let entry = Entry::new(EntryId::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(EntryId::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_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, StructuralLinkSettings::disabled()),
(FIELD_AREA, StructuralLinkSettings::enabled()),
]),
..EntryDirectoryCheckSettings::default()
};
let directory = entry_directory(&root);
directory.generate_links(&settings.structural).unwrap();
let report = directory
.rename_entry(
&EntryId::new("old-entry").unwrap(),
&EntryId::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(), &EntryId::new("old-entry").unwrap());
assert_eq!(report.new_id(), &EntryId::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!(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_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(
&EntryId::new("old-entry").unwrap(),
&EntryId::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(
&EntryId::new("old-entry").unwrap(),
&EntryId::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(&EntryId::new("alpha").unwrap()).unwrap();
let source = fs::read_to_string(&path).unwrap();
assert!(source.contains("frozen:\n"));
assert_path_readonly(&path);
}
#[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.
frozen:
---
Body.
",
);
let path =
entry_directory(temp.path()).freeze_entry(&EntryId::new("alpha").unwrap()).unwrap();
entry_directory(temp.path()).melt_entry(&EntryId::new("alpha").unwrap()).unwrap();
let source = fs::read_to_string(&path).unwrap();
assert!(!source.contains("frozen:\n"));
assert_path_writable(&path);
}
#[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(EntryId::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_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(EntryId::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();
entry_directory(&root).set_readonly(&settings).unwrap();
assert!(fs::metadata(root.join("concept.md")).unwrap().permissions().readonly());
assert!(fs::metadata(&root).unwrap().permissions().readonly());
entry_directory(&root).set_writable(&settings).unwrap();
assert!(!fs::metadata(root.join("concept.md")).unwrap().permissions().readonly());
assert!(!fs::metadata(&root).unwrap().permissions().readonly());
}
#[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(EntryId::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();
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(), 4);
assert_eq!(report.changed_paths().len(), 4);
assert!(concept.contains(crate::links::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_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, StructuralLinkSettings::new(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::links::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::links::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();
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 {
link: 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();
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 {
link: 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();
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 {
link: 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());
}
}