use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::sanitize;
use crate::store;
use super::types::{QuarantineManifest, QuarantineManifestItem, QuarantineRestoreReport};
#[derive(Debug, Default)]
pub(crate) struct EmptyBodyReport {
pub(crate) total: usize,
pub(crate) empty: usize,
pub(crate) empty_paths: Vec<PathBuf>,
pub(crate) sample_paths: Vec<PathBuf>,
pub(crate) by_frame_kind: BTreeMap<String, usize>,
}
pub(crate) fn empty_body_report(base: &Path) -> EmptyBodyReport {
let files = store::scan_context_files_at(base).unwrap_or_default();
let mut report = EmptyBodyReport {
total: files.len(),
..Default::default()
};
for file in files {
if file.path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue;
}
let Ok(content) = sanitize::read_to_string_validated(&file.path) else {
continue;
};
if !store::chunk_body_is_empty(&content)
&& crate::card_header::card_body(&content).trim().len() >= 50
{
continue;
}
report.empty += 1;
report.empty_paths.push(file.path.clone());
if report.sample_paths.len() < 20 {
report.sample_paths.push(file.path.clone());
}
let frame_kind = store::load_sidecar(&file.path)
.and_then(|sidecar| sidecar.frame_kind)
.or_else(|| {
crate::card_header::parse_card_header(&content).and_then(|header| header.frame_kind)
})
.map(|kind| kind.as_str().to_string())
.unwrap_or_else(|| "unknown".to_string());
*report.by_frame_kind.entry(frame_kind).or_insert(0) += 1;
}
report
}
pub fn render_prune_empty_bodies_script(base: &Path) -> Result<String> {
let report = empty_body_report(base);
let mut out = String::from("#!/usr/bin/env bash\nset -euo pipefail\n\n");
let timestamp = empty_body_quarantine_timestamp();
let quarantine_root = empty_body_quarantine_root(base, ×tamp);
out.push_str("# Review before running. Generated by `aicx doctor --prune-empty-bodies`.\n");
out.push_str("# Moves empty-body chunks into recoverable quarantine; no files are deleted.\n");
for path in report.empty_paths {
let dst = empty_body_quarantine_destination(base, &quarantine_root, &path)?;
if let Some(parent) = dst.parent() {
out.push_str("mkdir -p -- ");
out.push_str(&shell_quote_path(parent));
out.push('\n');
}
out.push_str("mv -n -- ");
out.push_str(&shell_quote_path(&path));
out.push(' ');
out.push_str(&shell_quote_path(&dst));
out.push('\n');
let sidecar = path.with_extension("meta.json");
if sidecar.exists() {
out.push_str("mv -n -- ");
out.push_str(&shell_quote_path(&sidecar));
out.push(' ');
out.push_str(&shell_quote_path(&dst.with_extension("meta.json")));
out.push('\n');
}
}
if !out.contains("mv -n --") {
out.push_str("# No empty-body chunks detected.\n");
}
Ok(out)
}
#[derive(Debug, Default)]
pub(crate) struct EmptyBodyQuarantineApplyReport {
pub(crate) quarantine_root: Option<PathBuf>,
pub(crate) manifest_path: Option<PathBuf>,
pub(crate) moved_chunks: usize,
pub(crate) moved_sidecars: usize,
pub(crate) failures: Vec<String>,
pub(crate) manifest_items: Vec<QuarantineManifestItem>,
}
pub(crate) struct EmptyBodyMove {
pub(crate) moved_sidecar: bool,
pub(crate) manifest_items: Vec<QuarantineManifestItem>,
}
pub(crate) fn apply_empty_body_quarantine(base: &Path) -> Result<EmptyBodyQuarantineApplyReport> {
let timestamp = empty_body_quarantine_timestamp();
apply_empty_body_quarantine_with_timestamp(base, ×tamp)
}
pub(crate) fn apply_empty_body_quarantine_with_timestamp(
base: &Path,
timestamp: &str,
) -> Result<EmptyBodyQuarantineApplyReport> {
let report = empty_body_report(base);
let quarantine_root = empty_body_quarantine_root(base, timestamp);
let slug = quarantine_root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(timestamp)
.to_string();
let mut apply_report = EmptyBodyQuarantineApplyReport::default();
if report.empty_paths.is_empty() {
return Ok(apply_report);
}
apply_report.quarantine_root = Some(quarantine_root.clone());
for path in report.empty_paths {
match quarantine_empty_body_chunk(base, &quarantine_root, &path) {
Ok(moved) => {
apply_report.moved_chunks += 1;
if moved.moved_sidecar {
apply_report.moved_sidecars += 1;
}
apply_report.manifest_items.extend(moved.manifest_items);
}
Err(e) => apply_report
.failures
.push(format!("{}: {e}", path.display())),
}
}
if !apply_report.manifest_items.is_empty() {
let manifest = QuarantineManifest {
schema_version: 1,
category: "empty_bodies".to_string(),
slug,
created_at: chrono::Utc::now().to_rfc3339(),
items: apply_report.manifest_items.clone(),
};
let manifest_path = quarantine_root.join("manifest.json");
if let Some(parent) = manifest_path.parent() {
std::fs::create_dir_all(parent).context("create quarantine manifest parent")?;
}
std::fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)
.with_context(|| format!("write quarantine manifest {}", manifest_path.display()))?;
apply_report.manifest_path = Some(manifest_path);
}
Ok(apply_report)
}
pub(crate) fn quarantine_empty_body_chunk(
base: &Path,
quarantine_root: &Path,
path: &Path,
) -> Result<EmptyBodyMove> {
let dst = empty_body_quarantine_destination(base, quarantine_root, path)?;
if dst.exists() {
bail!("destination already exists: {}", dst.display());
}
let chunk_sha = file_sha256(path)?;
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).context("create empty-body quarantine parent")?;
}
std::fs::rename(path, &dst)
.with_context(|| format!("rename empty-body chunk to {}", dst.display()))?;
let mut manifest_items = vec![QuarantineManifestItem {
original_path: path.to_path_buf(),
quarantined_path: dst.clone(),
sha256: chunk_sha,
}];
let sidecar = path.with_extension("meta.json");
if !sidecar.exists() {
return Ok(EmptyBodyMove {
moved_sidecar: false,
manifest_items,
});
}
let sidecar_dst = dst.with_extension("meta.json");
if sidecar_dst.exists() {
bail!(
"sidecar destination already exists: {}",
sidecar_dst.display()
);
}
let sidecar_sha = file_sha256(&sidecar)?;
std::fs::rename(&sidecar, &sidecar_dst)
.with_context(|| format!("rename empty-body sidecar to {}", sidecar_dst.display()))?;
manifest_items.push(QuarantineManifestItem {
original_path: sidecar,
quarantined_path: sidecar_dst,
sha256: sidecar_sha,
});
Ok(EmptyBodyMove {
moved_sidecar: true,
manifest_items,
})
}
pub(crate) fn empty_body_quarantine_destination(
base: &Path,
quarantine_root: &Path,
path: &Path,
) -> Result<PathBuf> {
let aicx_root = std::fs::canonicalize(base)
.with_context(|| format!("canonicalize aicx root: {}", base.display()))?;
let chunk_path = std::fs::canonicalize(path)
.with_context(|| format!("canonicalize empty-body chunk: {}", path.display()))?;
let relative = chunk_path.strip_prefix(&aicx_root).with_context(|| {
format!(
"empty-body chunk path '{}' is outside aicx canonical root '{}'",
path.display(),
aicx_root.display()
)
})?;
Ok(quarantine_root.join(relative))
}
pub(crate) fn empty_body_quarantine_root(base: &Path, timestamp: &str) -> PathBuf {
base.join("quarantine")
.join(format!("empty-bodies-{timestamp}"))
}
pub(crate) fn empty_body_quarantine_timestamp() -> String {
chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string()
}
pub(crate) fn file_sha256(path: &Path) -> Result<String> {
let bytes = sanitize::read_to_string_validated(path)
.with_context(|| format!("read {}", path.display()))?;
let mut hasher = Sha256::new();
hasher.update(bytes.as_bytes());
Ok(format!("{:x}", hasher.finalize()))
}
pub fn restore_quarantine(slug: &str) -> Result<QuarantineRestoreReport> {
let base = store::store_base_dir().context("Failed to resolve aicx store base directory")?;
restore_quarantine_at(&base, slug)
}
pub fn restore_quarantine_at(base: &Path, slug: &str) -> Result<QuarantineRestoreReport> {
let manifest_path = find_quarantine_manifest(base, slug)?
.with_context(|| format!("No quarantine manifest found for slug `{slug}`"))?;
let bytes = sanitize::read_to_string_validated(&manifest_path)
.with_context(|| format!("read quarantine manifest {}", manifest_path.display()))?;
let manifest: QuarantineManifest = serde_json::from_str(&bytes)
.with_context(|| format!("parse quarantine manifest {}", manifest_path.display()))?;
let mut report = QuarantineRestoreReport {
slug: if manifest.slug.is_empty() {
slug.to_string()
} else {
manifest.slug.clone()
},
manifest_path,
restored: 0,
skipped: 0,
failures: Vec::new(),
};
for item in manifest.items {
match restore_quarantine_item(&item) {
Ok(RestoreItemOutcome::Restored) => report.restored += 1,
Ok(RestoreItemOutcome::Skipped) => report.skipped += 1,
Err(err) => report.failures.push(format!(
"{} -> {}: {err}",
item.quarantined_path.display(),
item.original_path.display()
)),
}
}
Ok(report)
}
pub(crate) enum RestoreItemOutcome {
Restored,
Skipped,
}
pub(crate) fn restore_quarantine_item(item: &QuarantineManifestItem) -> Result<RestoreItemOutcome> {
if item.original_path.as_os_str().is_empty() || item.quarantined_path.as_os_str().is_empty() {
bail!("manifest item is missing original_path or quarantined_path");
}
if item.original_path.exists() {
if !item.sha256.is_empty() && file_sha256(&item.original_path)? == item.sha256 {
return Ok(RestoreItemOutcome::Skipped);
}
bail!(
"target exists with different content: {}",
item.original_path.display()
);
}
if !item.quarantined_path.exists() {
bail!("quarantined file is missing");
}
if !item.sha256.is_empty() {
let current = file_sha256(&item.quarantined_path)?;
if current != item.sha256 {
bail!(
"quarantined hash mismatch: expected {}, got {}",
item.sha256,
current
);
}
}
if let Some(parent) = item.original_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create restore parent {}", parent.display()))?;
}
std::fs::rename(&item.quarantined_path, &item.original_path).with_context(|| {
format!(
"restore {} to {}",
item.quarantined_path.display(),
item.original_path.display()
)
})?;
Ok(RestoreItemOutcome::Restored)
}
pub(crate) fn find_quarantine_manifest(base: &Path, slug: &str) -> Result<Option<PathBuf>> {
let root = base.join("quarantine");
if !root.exists() {
return Ok(None);
}
let direct = root.join(slug).join("manifest.json");
if let Some(path) = quarantine_manifest_candidate(&root, &direct)? {
return Ok(Some(path));
}
let empty_body_direct = root
.join(format!("empty-bodies-{slug}"))
.join("manifest.json");
if let Some(path) = quarantine_manifest_candidate(&root, &empty_body_direct)? {
return Ok(Some(path));
}
find_quarantine_manifest_recursive(&root, slug)
}
pub(crate) fn find_quarantine_manifest_recursive(
dir: &Path,
slug: &str,
) -> Result<Option<PathBuf>> {
for entry in
sanitize::read_dir_validated(dir).with_context(|| format!("read {}", dir.display()))?
{
let entry = entry?;
if !entry.file_type().map(|ty| ty.is_dir()).unwrap_or(false) {
continue;
}
let path = entry.path();
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if name == slug || name == format!("empty-bodies-{slug}") {
let manifest = path.join("manifest.json");
if let Some(path) = quarantine_manifest_candidate(dir, &manifest)? {
return Ok(Some(path));
}
}
if let Some(found) = find_quarantine_manifest_recursive(&path, slug)? {
return Ok(Some(found));
}
}
Ok(None)
}
pub(crate) fn quarantine_manifest_candidate(root: &Path, path: &Path) -> Result<Option<PathBuf>> {
if !path.exists() {
return Ok(None);
}
let root = std::fs::canonicalize(root)
.with_context(|| format!("canonicalize quarantine root {}", root.display()))?;
let manifest = std::fs::canonicalize(path)
.with_context(|| format!("canonicalize quarantine manifest {}", path.display()))?;
if !manifest.starts_with(&root) {
bail!(
"quarantine manifest escaped quarantine root: {}",
manifest.display()
);
}
Ok(Some(manifest))
}
pub fn format_restore_text(report: &QuarantineRestoreReport) -> String {
let mut out = format!(
"Restored quarantine `{}` from {}\n\n restored: {}\n skipped: {}\n",
report.slug,
report.manifest_path.display(),
report.restored,
report.skipped
);
if !report.failures.is_empty() {
out.push_str(" failures:\n");
for failure in &report.failures {
out.push_str(&format!(" - {failure}\n"));
}
}
out
}
pub fn render_rebuild_sidecars_script(base: &Path) -> Result<String> {
let files = store::scan_context_files_at(base).unwrap_or_default();
let mut out = String::from("#!/usr/bin/env bash\nset -euo pipefail\n\n");
out.push_str(
"# Review before running. Rebuilds missing sidecars by forcing a corpus rescan.\n",
);
let missing = files
.iter()
.filter(|file| !file.path.with_extension("meta.json").exists())
.take(20)
.map(|file| file.path.display().to_string())
.collect::<Vec<_>>();
if missing.is_empty() {
out.push_str("# No missing sidecars detected.\n");
} else {
for path in missing {
out.push_str(&format!("# missing: {path}\n"));
}
out.push_str("aicx store --full-rescan\n");
}
Ok(out)
}
pub(crate) fn shell_quote_path(path: &Path) -> String {
let value = path.display().to_string();
let value = value.strip_prefix(r"\\?\").unwrap_or(&value);
let value = value.replace('\\', "/");
format!("'{}'", value.replace('\'', "'\\''"))
}
pub(crate) fn quarantine_bucket(store_root: &Path, bucket_name: &str) -> Result<PathBuf> {
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
quarantine_bucket_with_timestamp(store_root, bucket_name, ×tamp)
}
pub(crate) fn quarantine_bucket_with_timestamp(
store_root: &Path,
bucket_name: &str,
timestamp: &str,
) -> Result<PathBuf> {
let quarantine_root = store_root
.parent()
.context("store root has no parent for quarantine")?
.join("quarantine")
.join(timestamp);
std::fs::create_dir_all(&quarantine_root).context("create quarantine root")?;
let src = store_root.join(bucket_name);
let dst = quarantine_root.join(bucket_name);
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).context("create nested quarantine parent")?;
}
std::fs::rename(&src, &dst).with_context(|| {
format!(
"rename corpus bucket {} to {}",
src.display(),
dst.display()
)
})?;
Ok(dst)
}