siteforge 0.1.11

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)]
pub struct Config {
    pub archive_root: PathBuf,
    pub archive_profile: ArchiveOutputProfile,
    pub default_concurrency: usize,
    pub default_delay_ms: u64,
    pub max_depth: usize,
    pub max_pages: usize,
    pub max_page_size_bytes: u64,
    pub max_asset_size_bytes: u64,
    pub max_total_archive_size_bytes: u64,
    pub include_url_patterns: Vec<String>,
    pub exclude_url_patterns: Vec<String>,
    pub ocr_enabled: bool,
    pub ocr_backend_command: String,
    pub user_agent: String,
    pub timeout_secs: u64,
    pub retry_count: usize,
    pub ai_pack_token_budget: usize,
    pub render_js: bool,
    pub render_js_auto: bool,
    pub browser_command: Option<String>,
    pub browser_profile: Option<PathBuf>,
    pub render_wait_ms: u64,
}

#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ArchiveOutputProfile {
    #[default]
    Full,
    Agent,
    Lean,
}

impl ArchiveOutputProfile {
    pub fn stores_raw_pages(self) -> bool {
        matches!(self, Self::Full | Self::Agent)
    }

    pub fn stores_rendered_pages(self) -> bool {
        matches!(self, Self::Full | Self::Agent)
    }

    pub fn downloads_assets(self) -> bool {
        matches!(self, Self::Full | Self::Agent)
    }

    pub fn stores_markdown(self) -> bool {
        matches!(self, Self::Full)
    }

    pub fn stores_basic_markdown(self) -> bool {
        true
    }

    pub fn stores_json(self) -> bool {
        true
    }

    pub fn stores_text(self) -> bool {
        matches!(self, Self::Full)
    }

    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 {
    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)
    }

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

    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)
    }

    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))
        }
    }
}