siteforge 0.1.19

Archive websites into AI-readable local knowledge archives
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use directories::ProjectDirs;
use serde::{Deserialize, Serialize};

use crate::errors::{Result, SiteforgeError};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
/// Siteforge runtime configuration.
///
/// Values can be loaded from YAML with [`Config::load`] or constructed
/// directly for library use. Relative paths resolve from the current working
/// directory when operations run.
pub struct Config {
    /// Root directory where archive directories are created.
    pub archive_root: PathBuf,
    /// Artifact volume/profile for newly created archives.
    pub archive_profile: ArchiveOutputProfile,
    /// Default concurrent page fetch count.
    pub default_concurrency: usize,
    /// Default politeness delay between requests to the same origin, in milliseconds.
    pub default_delay_ms: u64,
    /// Default maximum link depth for full-site crawls.
    pub max_depth: usize,
    /// Default maximum number of pages to fetch per archive.
    pub max_pages: usize,
    /// Maximum bytes to read for a page response body. `0` means no body cap.
    pub max_page_size_bytes: u64,
    /// Maximum bytes to read for an asset response body. `0` means no body cap.
    pub max_asset_size_bytes: u64,
    /// Maximum total archive bytes. `0` disables the total archive cap.
    pub max_total_archive_size_bytes: u64,
    /// Optional URL glob allow-list. Empty means all in-scope URLs are eligible.
    pub include_url_patterns: Vec<String>,
    /// URL glob deny-list applied after scope checks.
    pub exclude_url_patterns: Vec<String>,
    /// Enable OCR extraction for likely code/image assets.
    pub ocr_enabled: bool,
    /// Shell command template for OCR. `{input}` is replaced with the asset path.
    pub ocr_backend_command: String,
    /// User-Agent sent by the HTTP fetcher and browser renderer.
    pub user_agent: String,
    /// Per-request timeout in seconds.
    pub timeout_secs: u64,
    /// Number of retry attempts for retryable fetch failures.
    pub retry_count: usize,
    /// Default token budget for generated AI context packs.
    pub ai_pack_token_budget: usize,
    /// Render pages with a browser before parsing when enabled.
    pub render_js: bool,
    /// With `render_js`, only render thin SPA-like pages instead of every page.
    pub render_js_auto: bool,
    /// Optional browser command or executable for JavaScript rendering.
    pub browser_command: Option<String>,
    /// Optional browser user-data directory/profile for authorized content.
    pub browser_profile: Option<PathBuf>,
    /// Browser virtual-time wait budget in milliseconds.
    pub render_wait_ms: u64,
}

#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
/// Controls how much artifact data each archived page writes.
pub enum ArchiveOutputProfile {
    /// Preserve all page artifacts: raw payloads, assets, Markdown variants, JSON, text, and chunks.
    #[default]
    Full,
    /// Keep raw evidence and agent-readable files, but skip duplicate plain text and YAML Markdown.
    Agent,
    /// Keep compact agent-readable Markdown, JSON, chunks, metadata, and checksums only.
    Lean,
}

impl ArchiveOutputProfile {
    /// Whether raw fetched HTML pages are stored.
    pub fn stores_raw_pages(self) -> bool {
        matches!(self, Self::Full | Self::Agent)
    }

    /// Whether rendered DOM captures are stored when JS rendering succeeds.
    pub fn stores_rendered_pages(self) -> bool {
        matches!(self, Self::Full | Self::Agent)
    }

    /// Whether page assets are downloaded into `raw/assets/`.
    pub fn downloads_assets(self) -> bool {
        matches!(self, Self::Full | Self::Agent)
    }

    /// Whether YAML-frontmatter Markdown is stored in `readable/markdown/`.
    pub fn stores_markdown(self) -> bool {
        matches!(self, Self::Full)
    }

    /// Whether no-frontmatter basic Markdown is stored in `readable/basic_markdown/`.
    pub fn stores_basic_markdown(self) -> bool {
        true
    }

    /// Whether structured page JSON is stored in `readable/json/`.
    pub fn stores_json(self) -> bool {
        true
    }

    /// Whether plain text duplicates are stored in `readable/text/`.
    pub fn stores_text(self) -> bool {
        matches!(self, Self::Full)
    }

    /// Whether semantic JSONL chunks are appended to `chunks/chunks.jsonl`.
    pub fn stores_chunks(self) -> bool {
        true
    }
}

impl Default for Config {
    fn default() -> Self {
        Self {
            archive_root: PathBuf::from("archives"),
            archive_profile: ArchiveOutputProfile::Full,
            default_concurrency: 4,
            default_delay_ms: 250,
            max_depth: 4,
            max_pages: 500,
            max_page_size_bytes: 50 * 1024 * 1024,
            max_asset_size_bytes: 25 * 1024 * 1024,
            max_total_archive_size_bytes: 5 * 1024 * 1024 * 1024,
            include_url_patterns: Vec::new(),
            exclude_url_patterns: vec![
                "**/login**".to_string(),
                "**/logout**".to_string(),
                "**/signup**".to_string(),
                "**/cart**".to_string(),
            ],
            ocr_enabled: false,
            ocr_backend_command: "tesseract {input} stdout".to_string(),
            user_agent: "siteforge/0.1 (+https://github.com/Tknott95/siteforge)".to_string(),
            timeout_secs: 30,
            retry_count: 2,
            ai_pack_token_budget: 120_000,
            render_js: false,
            render_js_auto: false,
            browser_command: None,
            browser_profile: None,
            render_wait_ms: 5_000,
        }
    }
}

impl Config {
    /// Load YAML configuration from `path`, or the platform default path when `None`.
    ///
    /// Missing files return [`Config::default`]. A configured concurrency of zero
    /// is normalized to one.
    pub fn load(path: Option<&Path>) -> Result<Self> {
        let path = match path {
            Some(path) => path.to_path_buf(),
            None => Self::default_path()?,
        };

        if !path.exists() {
            return Ok(Self::default());
        }

        let raw = fs::read_to_string(&path)?;
        let mut config: Config = serde_yaml::from_str(&raw)?;
        if config.default_concurrency == 0 {
            config.default_concurrency = 1;
        }
        Ok(config)
    }

    /// Return the platform default configuration path.
    pub fn default_path() -> Result<PathBuf> {
        let dirs = ProjectDirs::from("com", "siteforge", "siteforge")
            .ok_or(SiteforgeError::ConfigDirUnavailable)?;
        Ok(dirs.config_dir().join("config.yaml"))
    }

    /// Create the platform default configuration file when missing and return its path.
    pub fn ensure_default_file() -> Result<PathBuf> {
        let path = Self::default_path()?;
        if !path.exists() {
            if let Some(parent) = path.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::write(&path, serde_yaml::to_string(&Self::default())?)?;
        }
        Ok(path)
    }

    /// Resolve [`Config::archive_root`] to an absolute path.
    pub fn resolved_archive_root(&self) -> Result<PathBuf> {
        if self.archive_root.is_absolute() {
            Ok(self.archive_root.clone())
        } else {
            Ok(std::env::current_dir()?.join(&self.archive_root))
        }
    }
}