use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::error::{Error, Result};
use crate::project::ProjectLayout;
use crate::store::Store;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ScanClass {
ZeroByteFile,
OrphanParagraphRow,
MissingReferencedFile,
CorruptCommentsSidecar,
BdslibOnly,
}
impl ScanClass {
pub fn slug(&self) -> &'static str {
match self {
ScanClass::ZeroByteFile => "zero-byte-file",
ScanClass::OrphanParagraphRow => "orphan-paragraph-row",
ScanClass::MissingReferencedFile => "missing-referenced-file",
ScanClass::CorruptCommentsSidecar => "corrupt-comments-sidecar",
ScanClass::BdslibOnly => "bdslib-only",
}
}
pub fn from_slug(s: &str) -> Option<Self> {
Some(match s {
"zero-byte-file" => ScanClass::ZeroByteFile,
"orphan-paragraph-row" => ScanClass::OrphanParagraphRow,
"missing-referenced-file" => ScanClass::MissingReferencedFile,
"corrupt-comments-sidecar" => ScanClass::CorruptCommentsSidecar,
"bdslib-only" => ScanClass::BdslibOnly,
_ => return None,
})
}
pub const ALL: [ScanClass; 5] = [
ScanClass::ZeroByteFile,
ScanClass::OrphanParagraphRow,
ScanClass::MissingReferencedFile,
ScanClass::CorruptCommentsSidecar,
ScanClass::BdslibOnly,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScanSeverity {
Critical,
Warning,
Info,
}
impl ScanSeverity {
pub fn slug(&self) -> &'static str {
match self {
ScanSeverity::Critical => "critical",
ScanSeverity::Warning => "warning",
ScanSeverity::Info => "info",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanFinding {
pub class: ScanClass,
pub severity: ScanSeverity,
pub path: Option<String>,
pub detail: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScanReport {
pub version: String,
pub generated_at: String,
pub project_root: String,
pub findings: Vec<ScanFinding>,
}
impl ScanReport {
pub fn new(project_root: &Path) -> Self {
Self {
version: env!("CARGO_PKG_VERSION").to_string(),
generated_at: chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string(),
project_root: project_root.display().to_string(),
findings: Vec::new(),
}
}
pub fn count_at_or_above(&self, severity: ScanSeverity) -> usize {
self.findings
.iter()
.filter(|f| severity_at_or_above(f.severity, severity))
.count()
}
}
fn severity_at_or_above(have: ScanSeverity, want: ScanSeverity) -> bool {
let rank = |s| match s {
ScanSeverity::Info => 1,
ScanSeverity::Warning => 2,
ScanSeverity::Critical => 3,
};
rank(have) >= rank(want)
}
pub fn scan_project(
project: &Path,
selected: Option<ScanClass>,
) -> Result<ScanReport> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg).map_err(|e| Error::Store(e.to_string()))?;
let hierarchy =
crate::store::hierarchy::Hierarchy::load(&store).map_err(|e| Error::Store(e.to_string()))?;
let mut report = ScanReport::new(&layout.root);
let run = |c: ScanClass| selected.map_or(true, |s| s == c);
if run(ScanClass::ZeroByteFile) || run(ScanClass::BdslibOnly) {
for finding in scan_zero_byte_files(&layout, &hierarchy, &store) {
if run(finding.class) {
report.findings.push(finding);
}
}
}
if run(ScanClass::OrphanParagraphRow)
|| run(ScanClass::MissingReferencedFile)
|| run(ScanClass::BdslibOnly)
{
for finding in scan_orphans_and_missing(&layout, &hierarchy, &store) {
if run(finding.class) {
report.findings.push(finding);
}
}
}
if run(ScanClass::CorruptCommentsSidecar) {
report.findings.extend(scan_corrupt_comments(&layout, &hierarchy));
}
Ok(report)
}
fn bdslib_content_len(store: &Store, id: uuid::Uuid) -> Option<usize> {
match store.get_content(id) {
Ok(Some(bytes)) if !bytes.is_empty() => Some(bytes.len()),
_ => None,
}
}
fn scan_zero_byte_files(
layout: &ProjectLayout,
hierarchy: &crate::store::hierarchy::Hierarchy,
store: &Store,
) -> Vec<ScanFinding> {
let mut out: Vec<ScanFinding> = Vec::new();
for node in hierarchy.iter() {
let Some(rel) = node.file.as_ref() else { continue };
if !rel.ends_with(".typ") {
continue;
}
let abs = layout.root.join(rel);
let Ok(md) = std::fs::metadata(&abs) else { continue };
if md.len() == 0 {
match bdslib_content_len(store, node.id) {
Some(n) => out.push(ScanFinding {
class: ScanClass::BdslibOnly,
severity: ScanSeverity::Info,
path: Some(abs.display().to_string()),
detail: format!(
"paragraph `{}` has 0-byte disk file but bdslib holds {} bytes — re-save in the editor or autofix to rematerialize",
node.slug, n,
),
}),
None => out.push(ScanFinding {
class: ScanClass::ZeroByteFile,
severity: ScanSeverity::Critical,
path: Some(abs.display().to_string()),
detail: format!(
"paragraph `{}` resolves to a 0-byte file AND bdslib has no content — prose lost",
node.slug,
),
}),
}
}
}
out
}
fn scan_orphans_and_missing(
layout: &ProjectLayout,
hierarchy: &crate::store::hierarchy::Hierarchy,
store: &Store,
) -> Vec<ScanFinding> {
let mut out: Vec<ScanFinding> = Vec::new();
for node in hierarchy.iter() {
let Some(rel) = node.file.as_ref() else { continue };
let abs = layout.root.join(rel);
match std::fs::metadata(&abs) {
Ok(_) => continue,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Some(n) = bdslib_content_len(store, node.id) {
out.push(ScanFinding {
class: ScanClass::BdslibOnly,
severity: ScanSeverity::Info,
path: Some(abs.display().to_string()),
detail: format!(
"paragraph `{}` has no disk file but bdslib holds {} bytes — recoverable",
node.slug, n,
),
});
continue;
}
let class = if rel.contains("..") || rel.is_empty() {
ScanClass::MissingReferencedFile
} else {
ScanClass::OrphanParagraphRow
};
out.push(ScanFinding {
class,
severity: ScanSeverity::Warning,
path: Some(abs.display().to_string()),
detail: format!(
"paragraph row `{}` points at missing file {} and bdslib has no content either",
node.slug,
abs.display(),
),
});
}
Err(e) => {
out.push(ScanFinding {
class: ScanClass::MissingReferencedFile,
severity: ScanSeverity::Warning,
path: Some(abs.display().to_string()),
detail: format!(
"paragraph row `{}` -> {}: {e}",
node.slug,
abs.display(),
),
});
}
}
}
out
}
fn scan_corrupt_comments(
layout: &ProjectLayout,
hierarchy: &crate::store::hierarchy::Hierarchy,
) -> Vec<ScanFinding> {
let mut out: Vec<ScanFinding> = Vec::new();
for node in hierarchy.iter() {
let Some(rel) = node.file.as_ref() else { continue };
if !rel.ends_with(".typ") {
continue;
}
let abs = layout.root.join(rel);
let sidecar = sidecar_path_for(&abs);
if !sidecar.exists() {
continue;
}
let Ok(raw) = std::fs::read_to_string(&sidecar) else {
continue;
};
if raw.trim().is_empty() {
continue;
}
if serde_json::from_str::<serde_json::Value>(&raw).is_err() {
out.push(ScanFinding {
class: ScanClass::CorruptCommentsSidecar,
severity: ScanSeverity::Warning,
path: Some(sidecar.display().to_string()),
detail: format!(
"comments sidecar for `{}` doesn't parse as JSON",
node.slug,
),
});
}
}
out
}
fn sidecar_path_for(typ_path: &Path) -> PathBuf {
let mut s = typ_path.as_os_str().to_os_string();
s.push(".comments.json");
PathBuf::from(s)
}
pub fn apply_fix(
project: &Path,
finding: &ScanFinding,
) -> Result<String> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg).map_err(|e| Error::Store(e.to_string()))?;
let hierarchy =
crate::store::hierarchy::Hierarchy::load(&store).map_err(|e| Error::Store(e.to_string()))?;
match finding.class {
ScanClass::ZeroByteFile
| ScanClass::OrphanParagraphRow
| ScanClass::MissingReferencedFile => {
let abs = finding
.path
.as_deref()
.ok_or_else(|| Error::Store("finding has no path".into()))?;
let abs_path = std::path::PathBuf::from(abs);
let rel = abs_path
.strip_prefix(&layout.root)
.map_err(|e| Error::Store(format!("path {} not under project root: {e}", abs)))?
.to_string_lossy()
.into_owned();
let mut to_delete: Vec<uuid::Uuid> = Vec::new();
for node in hierarchy.iter() {
if node.file.as_deref() == Some(rel.as_str()) {
to_delete.push(node.id);
}
}
if to_delete.is_empty() {
return Err(Error::Store(format!(
"no DB row matches {rel} — was the project mutated between scan and fix?"
)));
}
store
.delete_subtree(std::path::Path::new(&rel), &to_delete)
.map_err(|e| Error::Store(format!("delete row {rel}: {e}")))?;
Ok(format!(
"deleted {} DB row(s) + file {} ({})",
to_delete.len(),
rel,
finding.class.slug()
))
}
ScanClass::CorruptCommentsSidecar => {
let abs = finding
.path
.as_deref()
.ok_or_else(|| Error::Store("finding has no path".into()))?;
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
let dest = format!("{abs}.corrupt-{stamp}.bak");
std::fs::rename(abs, &dest).map_err(Error::Io)?;
Ok(format!(
"moved corrupt sidecar {} → {}",
abs, dest
))
}
ScanClass::BdslibOnly => {
let abs = finding
.path
.as_deref()
.ok_or_else(|| Error::Store("finding has no path".into()))?;
let abs_path = std::path::PathBuf::from(abs);
let rel = abs_path
.strip_prefix(&layout.root)
.map_err(|e| Error::Store(format!("path {} not under project root: {e}", abs)))?
.to_string_lossy()
.into_owned();
let mut found_id: Option<uuid::Uuid> = None;
for node in hierarchy.iter() {
if node.file.as_deref() == Some(rel.as_str()) {
found_id = Some(node.id);
break;
}
}
let id = found_id.ok_or_else(|| {
Error::Store(format!(
"no DB row matches {rel} — was the project mutated between scan and fix?"
))
})?;
let bytes = store
.get_content(id)
.map_err(|e| Error::Store(format!("bdslib read for {rel}: {e}")))?
.ok_or_else(|| {
Error::Store(format!("bdslib has no content for {rel} — refusing to write empty file"))
})?;
if bytes.is_empty() {
return Err(Error::Store(format!(
"bdslib has 0-byte content for {rel} — refusing to write empty file"
)));
}
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent).map_err(Error::Io)?;
}
if let Ok(md) = std::fs::metadata(&abs_path) {
if md.len() > 0 {
return Err(Error::Store(format!(
"disk file {abs} grew non-empty between scan and fix — refusing to overwrite"
)));
}
}
crate::io_atomic::write(&abs_path, &bytes).map_err(Error::Io)?;
Ok(format!(
"rematerialized {} ({} bytes) from bdslib",
rel,
bytes.len()
))
}
}
}
pub fn log_fix(project: &Path, finding: &ScanFinding, outcome: &Result<String>) {
let path = project.join(".inkhaven").join("doctor.log");
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
let (kind, detail) = match outcome {
Ok(s) => ("OK", s.clone()),
Err(e) => ("ERR", e.to_string()),
};
let line = format!(
"{now}|{kind}|{}|{}\n",
finding.class.slug(),
detail.replace('\n', " "),
);
use std::io::Write;
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| f.write_all(line.as_bytes()));
}
pub fn print_human(report: &ScanReport) {
println!("Project scan");
println!(
" generated_at : {}\n project_root : {}",
report.generated_at, report.project_root,
);
if report.findings.is_empty() {
println!(" findings : none — project is clean");
return;
}
println!(" findings : {}", report.findings.len());
println!();
for (i, f) in report.findings.iter().enumerate() {
let path = f.path.as_deref().unwrap_or("-");
println!(
" [{n}] {sev:>8} · {class:<26} · {path}",
n = i + 1,
sev = f.severity.slug(),
class = f.class.slug(),
);
println!(" {}", f.detail);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn class_slugs_distinct_and_roundtrip() {
let mut seen = std::collections::HashSet::new();
for c in ScanClass::ALL {
assert!(seen.insert(c.slug()));
assert_eq!(ScanClass::from_slug(c.slug()), Some(c));
}
assert_eq!(ScanClass::from_slug("nonsense"), None);
}
#[test]
fn severity_ordering_critical_warning_info() {
assert!(super::severity_at_or_above(
ScanSeverity::Critical,
ScanSeverity::Warning
));
assert!(super::severity_at_or_above(
ScanSeverity::Warning,
ScanSeverity::Info
));
assert!(!super::severity_at_or_above(
ScanSeverity::Info,
ScanSeverity::Warning
));
}
#[test]
fn count_at_or_above_warning() {
let mut r = ScanReport::new(std::path::Path::new("/tmp/x"));
r.findings.push(ScanFinding {
class: ScanClass::ZeroByteFile,
severity: ScanSeverity::Critical,
path: None,
detail: String::new(),
});
r.findings.push(ScanFinding {
class: ScanClass::CorruptCommentsSidecar,
severity: ScanSeverity::Warning,
path: None,
detail: String::new(),
});
r.findings.push(ScanFinding {
class: ScanClass::OrphanParagraphRow,
severity: ScanSeverity::Info,
path: None,
detail: String::new(),
});
assert_eq!(r.count_at_or_above(ScanSeverity::Warning), 2);
assert_eq!(r.count_at_or_above(ScanSeverity::Critical), 1);
assert_eq!(r.count_at_or_above(ScanSeverity::Info), 3);
}
#[test]
fn sidecar_path_appends_comments_json() {
let p = std::path::Path::new("/tmp/x/foo.typ");
let s = sidecar_path_for(p);
assert_eq!(s.to_string_lossy(), "/tmp/x/foo.typ.comments.json");
}
#[test]
fn report_serialises_roundtrip() {
let mut r = ScanReport::new(std::path::Path::new("/tmp/x"));
r.findings.push(ScanFinding {
class: ScanClass::ZeroByteFile,
severity: ScanSeverity::Critical,
path: Some("/tmp/x/foo.typ".into()),
detail: "prose lost".into(),
});
let json = serde_json::to_string(&r).unwrap();
let parsed: ScanReport = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.findings.len(), 1);
assert_eq!(parsed.findings[0].class, ScanClass::ZeroByteFile);
assert_eq!(parsed.findings[0].path.as_deref(), Some("/tmp/x/foo.typ"));
}
}