use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use walkdir::WalkDir;
use crate::config::Config;
use crate::errors::{Result, SiteforgeError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveManifest {
pub archive_id: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub seeds: Vec<String>,
pub scope: ArchiveScopeSummary,
pub stats: CrawlStats,
pub config: ArchiveConfigSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveScopeSummary {
pub full_site: bool,
pub same_domain: bool,
pub max_depth: usize,
pub include_url_patterns: Vec<String>,
pub exclude_url_patterns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveConfigSnapshot {
pub user_agent: String,
pub concurrency: usize,
pub delay_ms: u64,
pub timeout_secs: u64,
pub retry_count: usize,
pub max_pages: usize,
pub max_asset_size_bytes: u64,
#[serde(default)]
pub max_total_archive_size_bytes: u64,
pub ocr_enabled: bool,
#[serde(default)]
pub render_js: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChecksumManifest {
pub generated_at: DateTime<Utc>,
pub files: Vec<ChecksumEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChecksumEntry {
pub path: String,
pub bytes: u64,
pub blake3: String,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct CrawlStats {
pub pages_discovered: usize,
pub pages_fetched: usize,
pub pages_parsed: usize,
pub pages_skipped: usize,
pub pages_failed: usize,
pub assets_discovered: usize,
pub assets_downloaded: usize,
pub assets_failed: usize,
pub ocr_attempted: usize,
pub ocr_succeeded: usize,
pub ocr_unavailable: usize,
}
#[derive(Debug, Clone)]
pub struct ArchiveLayout {
pub root: PathBuf,
pub archive_id: String,
}
impl ArchiveLayout {
pub fn new(root: PathBuf, archive_id: impl Into<String>) -> Self {
Self {
root,
archive_id: archive_id.into(),
}
}
pub fn base(&self) -> PathBuf {
self.root.join(&self.archive_id)
}
pub fn manifest(&self) -> PathBuf {
self.base().join("manifest.json")
}
pub fn database(&self) -> PathBuf {
self.base().join("crawl.db")
}
pub fn checksums(&self) -> PathBuf {
self.base().join("checksums.json")
}
pub fn raw_pages_dir(&self) -> PathBuf {
self.base().join("raw").join("pages")
}
pub fn rendered_pages_dir(&self) -> PathBuf {
self.base().join("raw").join("rendered")
}
pub fn raw_assets_dir(&self) -> PathBuf {
self.base().join("raw").join("assets")
}
pub fn readable_markdown_dir(&self) -> PathBuf {
self.base().join("readable").join("markdown")
}
pub fn readable_basic_markdown_dir(&self) -> PathBuf {
self.base().join("readable").join("basic_markdown")
}
pub fn readable_dir(&self) -> PathBuf {
self.base().join("readable")
}
pub fn agents_md(&self) -> PathBuf {
self.base().join("AGENTS.md")
}
pub fn agent_index_json(&self) -> PathBuf {
self.base().join("agent-index.json")
}
pub fn readable_json_dir(&self) -> PathBuf {
self.base().join("readable").join("json")
}
pub fn readable_text_dir(&self) -> PathBuf {
self.base().join("readable").join("text")
}
pub fn chunks_dir(&self) -> PathBuf {
self.base().join("chunks")
}
pub fn chunks_jsonl(&self) -> PathBuf {
self.chunks_dir().join("chunks.jsonl")
}
pub fn packs_dir(&self) -> PathBuf {
self.base().join("packs")
}
pub fn logs_dir(&self) -> PathBuf {
self.base().join("logs")
}
pub fn exports_dir(&self) -> PathBuf {
self.base().join("exports")
}
pub fn ensure(&self) -> Result<()> {
for path in [
self.raw_pages_dir(),
self.rendered_pages_dir(),
self.raw_assets_dir(),
self.readable_markdown_dir(),
self.readable_basic_markdown_dir(),
self.readable_json_dir(),
self.readable_text_dir(),
self.chunks_dir(),
self.packs_dir(),
self.logs_dir(),
self.exports_dir(),
] {
fs::create_dir_all(path)?;
}
Ok(())
}
}
pub fn new_manifest(
archive_id: String,
seeds: &[Url],
full_site: bool,
same_domain: bool,
max_depth: usize,
config: &Config,
) -> ArchiveManifest {
let now = Utc::now();
ArchiveManifest {
archive_id,
created_at: now,
updated_at: now,
seeds: seeds.iter().map(ToString::to_string).collect(),
scope: ArchiveScopeSummary {
full_site,
same_domain,
max_depth,
include_url_patterns: config.include_url_patterns.clone(),
exclude_url_patterns: config.exclude_url_patterns.clone(),
},
stats: CrawlStats::default(),
config: ArchiveConfigSnapshot {
user_agent: config.user_agent.clone(),
concurrency: config.default_concurrency,
delay_ms: config.default_delay_ms,
timeout_secs: config.timeout_secs,
retry_count: config.retry_count,
max_pages: config.max_pages,
max_asset_size_bytes: config.max_asset_size_bytes,
max_total_archive_size_bytes: config.max_total_archive_size_bytes,
ocr_enabled: config.ocr_enabled,
render_js: config.render_js,
},
}
}
pub fn write_manifest(layout: &ArchiveLayout, manifest: &ArchiveManifest) -> Result<()> {
let mut manifest = manifest.clone();
manifest.updated_at = Utc::now();
fs::write(layout.manifest(), serde_json::to_string_pretty(&manifest)?)?;
Ok(())
}
pub fn read_manifest(path: &Path) -> Result<ArchiveManifest> {
let raw = fs::read_to_string(path)?;
Ok(serde_json::from_str(&raw)?)
}
pub fn load_archive_manifest(config: &Config, archive_id: &str) -> Result<ArchiveManifest> {
let layout = ArchiveLayout::new(config.resolved_archive_root()?, archive_id.to_string());
let path = layout.manifest();
if !path.exists() {
return Err(SiteforgeError::ArchiveNotFound(archive_id.to_string()));
}
read_manifest(&path)
}
pub fn list_archives(config: &Config) -> Result<Vec<ArchiveManifest>> {
let root = config.resolved_archive_root()?;
if !root.exists() {
return Ok(Vec::new());
}
let mut archives = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
let manifest_path = entry.path().join("manifest.json");
if manifest_path.exists() {
match read_manifest(&manifest_path) {
Ok(manifest) => archives.push(manifest),
Err(err) => {
tracing::warn!(path = %manifest_path.display(), error = %err, "skipping unreadable archive manifest")
}
}
}
}
archives.sort_by_key(|archive| std::cmp::Reverse(archive.updated_at));
Ok(archives)
}
pub fn archive_id_from_seed(seed: &Url) -> String {
let host = seed.host_str().unwrap_or("archive");
let mut slug = host
.trim_start_matches("www.")
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect::<String>();
while slug.contains("--") {
slug = slug.replace("--", "-");
}
let timestamp = Utc::now().format("%Y%m%d%H%M%S");
format!("{}-{}", slug.trim_matches('-'), timestamp)
}
pub fn validate_archive_id(archive_id: &str) -> Result<()> {
let valid = !archive_id.is_empty()
&& archive_id.len() <= 128
&& archive_id != "."
&& archive_id != ".."
&& archive_id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'));
if valid {
Ok(())
} else {
Err(SiteforgeError::InvalidArchiveId(
"archive IDs must be 1-128 ASCII letters, numbers, '.', '_' or '-'".to_string(),
))
}
}
pub fn stable_id(input: &str) -> String {
blake3::hash(input.as_bytes()).to_hex()[..16].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_archive_ids() {
assert!(validate_archive_id("catlikecoding").is_ok());
assert!(validate_archive_id("catlikecoding_2026.06").is_ok());
assert!(validate_archive_id("../bad").is_err());
assert!(validate_archive_id("").is_err());
}
}
pub fn archive_size_bytes(layout: &ArchiveLayout) -> Result<u64> {
if !layout.base().exists() {
return Ok(0);
}
let mut total = 0u64;
for entry in WalkDir::new(layout.base())
.into_iter()
.filter_map(std::result::Result::ok)
{
if entry.file_type().is_file() && !is_transient_archive_file(layout, entry.path()) {
total = total.saturating_add(fs::metadata(entry.path())?.len());
}
}
Ok(total)
}
pub fn ensure_archive_size_can_grow(
config: &Config,
layout: &ArchiveLayout,
additional_bytes: u64,
path: &Path,
) -> Result<()> {
let limit = config.max_total_archive_size_bytes;
if limit == 0 {
return Ok(());
}
let current = archive_size_bytes(layout)?;
let projected = current.saturating_add(additional_bytes);
if projected > limit {
return Err(SiteforgeError::SizeLimitExceeded {
path: path.display().to_string(),
size: projected,
limit,
});
}
Ok(())
}
pub fn write_checksums(layout: &ArchiveLayout) -> Result<ChecksumManifest> {
let manifest = generate_checksums(layout)?;
fs::write(layout.checksums(), serde_json::to_string_pretty(&manifest)?)?;
Ok(manifest)
}
pub fn generate_checksums(layout: &ArchiveLayout) -> Result<ChecksumManifest> {
let mut files = Vec::new();
if !layout.base().exists() {
return Ok(ChecksumManifest {
generated_at: Utc::now(),
files,
});
}
for entry in WalkDir::new(layout.base())
.into_iter()
.filter_map(std::result::Result::ok)
{
if !entry.file_type().is_file() || is_transient_archive_file(layout, entry.path()) {
continue;
}
let relative = archive_relative_path(layout, entry.path());
let (bytes, blake3) = hash_file(entry.path())?;
files.push(ChecksumEntry {
path: relative,
bytes,
blake3,
});
}
files.sort_by(|a, b| a.path.cmp(&b.path));
Ok(ChecksumManifest {
generated_at: Utc::now(),
files,
})
}
pub fn verify_archive(config: &Config, archive_id: &str) -> Result<Vec<String>> {
let layout = ArchiveLayout::new(config.resolved_archive_root()?, archive_id.to_string());
if !layout.base().exists() {
return Err(SiteforgeError::ArchiveNotFound(archive_id.to_string()));
}
let mut problems = Vec::new();
for required in [
layout.manifest(),
layout.database(),
layout.raw_pages_dir(),
layout.readable_markdown_dir(),
layout.readable_json_dir(),
layout.chunks_jsonl(),
layout.checksums(),
] {
if !required.exists() {
problems.push(format!("missing {}", required.display()));
}
}
if layout.checksums().exists() {
let raw = fs::read_to_string(layout.checksums())?;
let checksums: ChecksumManifest = serde_json::from_str(&raw)?;
for entry in checksums.files {
let path = layout.base().join(&entry.path);
if !path.exists() {
problems.push(format!("checksum entry missing file {}", entry.path));
continue;
}
let (bytes, blake3) = hash_file(&path)?;
if bytes != entry.bytes {
problems.push(format!(
"size mismatch {}: expected {} bytes, got {} bytes",
entry.path, entry.bytes, bytes
));
}
if blake3 != entry.blake3 {
problems.push(format!("hash mismatch {}", entry.path));
}
}
}
Ok(problems)
}
fn archive_relative_path(layout: &ArchiveLayout, path: &Path) -> String {
path.strip_prefix(layout.base())
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn is_transient_archive_file(layout: &ArchiveLayout, path: &Path) -> bool {
if path == layout.checksums() {
return true;
}
let relative = archive_relative_path(layout, path);
relative.starts_with("exports/")
|| relative.ends_with(".db-wal")
|| relative.ends_with(".db-shm")
}
fn hash_file(path: &Path) -> Result<(u64, String)> {
let mut file = fs::File::open(path)?;
let mut hasher = blake3::Hasher::new();
let mut bytes = 0u64;
let mut buffer = [0u8; 16 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
bytes = bytes.saturating_add(read as u64);
hasher.update(&buffer[..read]);
}
Ok((bytes, hasher.finalize().to_hex().to_string()))
}