use std::fs;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use tempfile::NamedTempFile;
use crate::domain::{ArchivedSource, ResearchNote, SourceDigest, StagedSource, StoredNote};
use crate::ports::{VaultError, VaultStore};
pub fn prepare_vault_directory(root: &Path, relative: &Path) -> Result<PathBuf, VaultError> {
ensure_safe_directory(root, relative)
}
#[derive(Debug, Clone)]
pub struct VaultFileStore {
vault_root: PathBuf,
research_directory: PathBuf,
archive_directory: PathBuf,
}
impl VaultFileStore {
#[must_use]
pub fn new(
vault_root: PathBuf,
research_directory: PathBuf,
archive_directory: PathBuf,
) -> Self {
Self {
vault_root,
research_directory,
archive_directory,
}
}
fn destination(&self, note: &ResearchNote) -> PathBuf {
let slug = slugify(¬e.draft.title);
let digest_prefix = ¬e.source_digest.as_str()[..12];
self.research_directory
.join(format!("{slug}-{digest_prefix}.md"))
}
pub fn prepare_directories(&self) -> Result<(), VaultError> {
ensure_safe_directory(&self.vault_root, &self.research_directory)?;
ensure_safe_directory(&self.vault_root, &self.archive_directory)?;
Ok(())
}
}
impl VaultStore for VaultFileStore {
fn find_by_digest(&self, digest: &SourceDigest) -> Result<Option<StoredNote>, VaultError> {
let directory = ensure_safe_directory(&self.vault_root, &self.research_directory)?;
if !directory.exists() {
return Ok(None);
}
let needle = format!("source_digest: {digest}");
for entry in fs::read_dir(&directory).map_err(VaultError::Filesystem)? {
let entry = entry.map_err(VaultError::Filesystem)?;
if !entry.file_type().map_err(VaultError::Filesystem)?.is_file() {
continue;
}
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("md") {
continue;
}
let content = fs::read_to_string(&path).map_err(VaultError::Filesystem)?;
if content.lines().any(|line| line == needle) {
let relative_path = path
.strip_prefix(&self.vault_root)
.map_err(|error| VaultError::Rendering(error.to_string()))?
.to_path_buf();
return Ok(Some(StoredNote { relative_path }));
}
}
Ok(None)
}
fn create_note(&self, note: &ResearchNote) -> Result<StoredNote, VaultError> {
let relative_path = self.destination(note);
let parent = ensure_safe_directory(&self.vault_root, &self.research_directory)?;
let destination = self.vault_root.join(&relative_path);
if destination.exists() {
return Err(VaultError::AlreadyExists(
relative_path.display().to_string(),
));
}
let rendered = render_note(note)?;
let mut temporary = NamedTempFile::new_in(parent).map_err(VaultError::Filesystem)?;
temporary
.write_all(rendered.as_bytes())
.map_err(VaultError::Filesystem)?;
temporary
.as_file()
.sync_all()
.map_err(VaultError::Filesystem)?;
temporary.persist_noclobber(&destination).map_err(|error| {
if error.error.kind() == std::io::ErrorKind::AlreadyExists {
VaultError::AlreadyExists(relative_path.display().to_string())
} else {
VaultError::Filesystem(error.error)
}
})?;
Ok(StoredNote { relative_path })
}
fn archive_source(&self, source: &StagedSource) -> Result<ArchivedSource, VaultError> {
let relative_path = self.archive_directory.join(format!(
"{}-{}",
&source.digest().as_str()[..12],
source.source_name()
));
let parent = ensure_safe_directory(&self.vault_root, &self.archive_directory)?;
let destination = self.vault_root.join(&relative_path);
if destination.exists() {
let metadata = fs::symlink_metadata(&destination).map_err(VaultError::Filesystem)?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(VaultError::UnsafePath(destination.display().to_string()));
}
let archived_bytes = fs::read(&destination).map_err(VaultError::Filesystem)?;
if crate::domain::SourceDigest::from_bytes(&archived_bytes) != *source.digest() {
return Err(VaultError::DigestMismatch);
}
if source.path() != source.original_path() && source.path().exists() {
fs::remove_file(source.path()).map_err(VaultError::Filesystem)?;
}
return Ok(ArchivedSource { relative_path });
}
let source_bytes = fs::read(source.path()).map_err(VaultError::Filesystem)?;
if &crate::domain::SourceDigest::from_bytes(&source_bytes) != source.digest() {
return Err(VaultError::DigestMismatch);
}
if source.original_path().exists() {
let original_bytes =
fs::read(source.original_path()).map_err(VaultError::Filesystem)?;
if crate::domain::SourceDigest::from_bytes(&original_bytes) != *source.digest() {
return Err(VaultError::DigestMismatch);
}
}
let mut temporary = NamedTempFile::new_in(parent).map_err(VaultError::Filesystem)?;
temporary
.write_all(&source_bytes)
.map_err(VaultError::Filesystem)?;
temporary
.as_file()
.sync_all()
.map_err(VaultError::Filesystem)?;
temporary.persist_noclobber(&destination).map_err(|error| {
if error.error.kind() == std::io::ErrorKind::AlreadyExists {
VaultError::AlreadyExists(relative_path.display().to_string())
} else {
VaultError::Filesystem(error.error)
}
})?;
if source.path() != source.original_path() && source.path().exists() {
fs::remove_file(source.path()).map_err(VaultError::Filesystem)?;
}
Ok(ArchivedSource { relative_path })
}
}
fn ensure_safe_directory(root: &Path, relative: &Path) -> Result<PathBuf, VaultError> {
if relative.is_absolute()
|| relative
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(VaultError::UnsafePath(relative.display().to_string()));
}
let root = root.canonicalize().map_err(VaultError::Filesystem)?;
let mut current = root.clone();
for component in relative.components() {
let Component::Normal(name) = component else {
return Err(VaultError::UnsafePath(relative.display().to_string()));
};
current.push(name);
match fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
return Err(VaultError::UnsafePath(current.display().to_string()));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir(¤t).map_err(VaultError::Filesystem)?;
}
Err(error) => return Err(VaultError::Filesystem(error)),
}
}
let canonical = current.canonicalize().map_err(VaultError::Filesystem)?;
if !canonical.starts_with(root) {
return Err(VaultError::UnsafePath(canonical.display().to_string()));
}
Ok(canonical)
}
fn slugify(title: &str) -> String {
let mut slug = String::new();
let mut separator_pending = false;
for character in title.chars().flat_map(char::to_lowercase) {
if character.is_alphanumeric() {
if separator_pending && !slug.is_empty() {
slug.push('-');
}
slug.push(character);
separator_pending = false;
} else {
separator_pending = true;
}
}
if slug.is_empty() {
"research".to_owned()
} else {
slug
}
}
fn render_note(note: &ResearchNote) -> Result<String, VaultError> {
let citation = yaml_value(¬e.draft.citation)?;
let source_file = yaml_value(note.source_name.as_str())?;
let topics = yaml_value(¬e.draft.topics)?;
let baml_function = yaml_value(¬e.analysis.function)?;
let baml_client = yaml_value(¬e.analysis.client)?;
let baml_model = yaml_value(¬e.analysis.model)?;
let pipeline_version = yaml_value(¬e.analysis.pipeline_version)?;
let processed_at = yaml_value(¬e.analysis.processed_at)?;
let key_ideas = markdown_list(¬e.draft.key_ideas);
let implementation_notes = markdown_list(¬e.draft.implementation_notes);
let evidence = note
.draft
.evidence
.iter()
.map(|reference| {
format!(
"- {} — {}",
inert_markdown(&reference.quote),
inert_markdown(&reference.location)
)
})
.collect::<Vec<_>>()
.join("\n");
Ok(format!(
"---\n\
type: research\n\
source_type: document\n\
citation: {citation}\n\
topic: {topics}\n\
status: unprocessed\n\
tags: [research, ingested]\n\
source_digest: {}\n\
source_file: {source_file}\n\
extraction_method: {}\n\
baml_function: {baml_function}\n\
baml_client: {baml_client}\n\
baml_model: {baml_model}\n\
pipeline_version: {pipeline_version}\n\
processed: {processed_at}\n\
---\n\n\
# {}\n\n\
## Summary\n\n{}\n\n\
## Key Ideas\n\n{key_ideas}\n\n\
## Implementation Notes\n\n{implementation_notes}\n\n\
## Critique\n\n{}\n\n\
## Evidence\n\n{evidence}\n",
note.source_digest,
note.extraction_method.as_str(),
inert_markdown(¬e.draft.title),
inert_markdown(¬e.draft.summary),
inert_markdown(¬e.draft.critique),
))
}
fn yaml_value(value: &(impl serde::Serialize + ?Sized)) -> Result<String, VaultError> {
serde_json::to_string(value).map_err(|error| VaultError::Rendering(error.to_string()))
}
fn inert_markdown(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
match character {
'\0' => escaped.push('�'),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+' | '-' | '.'
| '!' | '|' => {
escaped.push('\\');
escaped.push(character);
}
_ => escaped.push(character),
}
}
escaped
}
fn markdown_list(items: &[String]) -> String {
if items.is_empty() {
return "- None noted".to_owned();
}
items
.iter()
.map(|item| format!("- {}", inert_markdown(item)))
.collect::<Vec<_>>()
.join("\n")
}