use std::collections::HashSet;
use super::body_doc_manager::BodyDocManager;
use super::workspace_doc::WorkspaceCrdt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SanityIssue {
pub key: Option<String>,
pub kind: IssueKind,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueKind {
EmptyBody,
BrokenParentChain,
OrphanBodyDoc,
MissingBodyDoc,
MissingChild,
}
#[derive(Debug)]
pub struct SanityReport {
pub issues: Vec<SanityIssue>,
pub file_count: usize,
pub body_doc_count: usize,
}
impl SanityReport {
pub fn is_ok(&self) -> bool {
self.issues.is_empty()
}
pub fn issue_count(&self) -> usize {
self.issues.len()
}
pub fn issues_of_kind(&self, kind: &IssueKind) -> Vec<&SanityIssue> {
self.issues.iter().filter(|i| &i.kind == kind).collect()
}
}
pub fn validate_workspace(
workspace: &WorkspaceCrdt,
body_docs: &BodyDocManager,
workspace_id: &str,
) -> SanityReport {
let files = workspace.list_files();
let mut issues = Vec::new();
let mut active_keys: HashSet<String> = HashSet::new();
let mut all_keys: HashSet<String> = HashSet::new();
let mut expected_body_keys: HashSet<String> = HashSet::new();
for (key, _) in &files {
all_keys.insert(key.clone());
}
let mut file_count = 0;
let mut body_doc_count = 0;
for (key, meta) in &files {
if meta.deleted {
continue;
}
file_count += 1;
active_keys.insert(key.clone());
let path = if key.contains('/') || key.ends_with(".md") {
key.clone()
} else if let Some(p) = workspace.get_path(key) {
p.to_string_lossy().to_string()
} else {
key.clone()
};
let body_key = format!("body:{}/{}", workspace_id, path);
expected_body_keys.insert(body_key.clone());
if let Some(body_doc) = body_docs.get(&body_key) {
body_doc_count += 1;
let is_index = meta.contents.is_some();
if body_doc.get_body().is_empty() && !is_index {
issues.push(SanityIssue {
key: Some(key.clone()),
kind: IssueKind::EmptyBody,
message: format!("Non-index file '{}' has an empty body", path),
});
}
}
if let Some(ref parent_id) = meta.part_of {
if !parent_id.is_empty()
&& !parent_id.contains('/')
&& !parent_id.ends_with(".md")
&& !all_keys.contains(parent_id)
{
issues.push(SanityIssue {
key: Some(key.clone()),
kind: IssueKind::BrokenParentChain,
message: format!(
"File '{}' references non-existent parent '{}'",
key, parent_id
),
});
}
}
if let Some(ref contents) = meta.contents {
for child_ref in contents {
if !child_ref.contains('/') && !child_ref.ends_with(".md") {
if !all_keys.contains(child_ref) {
issues.push(SanityIssue {
key: Some(key.clone()),
kind: IssueKind::MissingChild,
message: format!(
"File '{}' lists non-existent child '{}'",
key, child_ref
),
});
}
}
}
}
}
SanityReport {
issues,
file_count,
body_doc_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crdt::{BodyDocManager, FileMetadata, MemoryStorage, WorkspaceCrdt};
use std::sync::Arc;
fn setup() -> (Arc<MemoryStorage>, WorkspaceCrdt, BodyDocManager) {
let storage = Arc::new(MemoryStorage::new());
let workspace = WorkspaceCrdt::new(storage.clone());
let body_docs = BodyDocManager::new(storage.clone());
(storage, workspace, body_docs)
}
#[test]
fn test_empty_workspace_is_ok() {
let (_storage, workspace, body_docs) = setup();
let report = validate_workspace(&workspace, &body_docs, "ws");
assert!(report.is_ok());
assert_eq!(report.file_count, 0);
}
#[test]
fn test_healthy_workspace() {
let (_storage, workspace, body_docs) = setup();
let meta = FileMetadata::with_filename("note.md".to_string(), Some("Note".to_string()));
let doc_id = workspace.create_file(meta).unwrap();
let path = workspace.get_path(&doc_id).unwrap();
let body_key = format!("body:ws/{}", path.to_string_lossy());
body_docs
.get_or_create(&body_key)
.set_body("Some content")
.unwrap();
let report = validate_workspace(&workspace, &body_docs, "ws");
assert!(report.is_ok());
assert_eq!(report.file_count, 1);
}
#[test]
fn test_broken_parent_chain() {
let (_storage, workspace, body_docs) = setup();
let mut meta =
FileMetadata::with_filename("orphan.md".to_string(), Some("Orphan".to_string()));
meta.part_of = Some("non-existent-uuid".to_string());
workspace.set_file("some-uuid", meta).unwrap();
let report = validate_workspace(&workspace, &body_docs, "ws");
assert!(!report.is_ok());
assert_eq!(
report.issues_of_kind(&IssueKind::BrokenParentChain).len(),
1
);
}
#[test]
fn test_missing_child() {
let (_storage, workspace, body_docs) = setup();
let mut meta =
FileMetadata::with_filename("index.md".to_string(), Some("Index".to_string()));
meta.contents = Some(vec!["non-existent-child-uuid".to_string()]);
workspace.set_file("parent-uuid", meta).unwrap();
let report = validate_workspace(&workspace, &body_docs, "ws");
assert!(!report.is_ok());
assert_eq!(report.issues_of_kind(&IssueKind::MissingChild).len(), 1);
}
#[test]
fn test_deleted_files_skipped() {
let (_storage, workspace, body_docs) = setup();
let mut meta =
FileMetadata::with_filename("deleted.md".to_string(), Some("Deleted".to_string()));
meta.mark_deleted();
workspace.create_file(meta).unwrap();
let report = validate_workspace(&workspace, &body_docs, "ws");
assert!(report.is_ok());
assert_eq!(report.file_count, 0);
}
#[test]
fn test_empty_body_detected() {
let (_storage, workspace, body_docs) = setup();
let meta = FileMetadata::with_filename("empty.md".to_string(), Some("Empty".to_string()));
let doc_id = workspace.create_file(meta).unwrap();
let path = workspace.get_path(&doc_id).unwrap();
let body_key = format!("body:ws/{}", path.to_string_lossy());
body_docs.get_or_create(&body_key);
let report = validate_workspace(&workspace, &body_docs, "ws");
assert_eq!(report.issues_of_kind(&IssueKind::EmptyBody).len(), 1);
}
#[test]
fn test_index_files_allowed_empty_body() {
let (_storage, workspace, body_docs) = setup();
let mut meta =
FileMetadata::with_filename("index.md".to_string(), Some("Index".to_string()));
meta.contents = Some(vec![]); let doc_id = workspace.create_file(meta).unwrap();
let path = workspace.get_path(&doc_id).unwrap();
let body_key = format!("body:ws/{}", path.to_string_lossy());
body_docs.get_or_create(&body_key);
let report = validate_workspace(&workspace, &body_docs, "ws");
assert_eq!(report.issues_of_kind(&IssueKind::EmptyBody).len(), 0);
}
}