conpub 0.1.0

Agent-first CLI for publishing local knowledge files to Confluence Cloud
use crate::domain::*;
use crate::infrastructure::filesystem::{is_ignored, is_supported_doc};
use crate::support::*;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::{DirEntry, WalkDir};

const SHARED_ASSETS_DIR: &str = "_assets";
const STAGED_ASSETS_DIR: &str = "assets";

pub(crate) fn snapshot_documents(
    root: &Path,
    documents: &[Document],
) -> AppResult<Vec<DocumentSnapshot>> {
    let shared_assets_fingerprint = fingerprint_shared_assets(root)?;
    documents
        .iter()
        .map(|document| {
            Ok(DocumentSnapshot {
                document: document.clone(),
                slug: slug_for_path(&document.path),
                fingerprint: fingerprint_document(root, document, &shared_assets_fingerprint)?,
                parent_path: None,
                hierarchy_order: 0,
            })
        })
        .collect()
}

pub(crate) fn snapshot_hierarchy(
    root: &Path,
    entries: &[HierarchyEntry],
) -> AppResult<Vec<DocumentSnapshot>> {
    let shared_assets_fingerprint = fingerprint_shared_assets(root)?;
    entries
        .iter()
        .map(|entry| {
            Ok(DocumentSnapshot {
                document: entry.document.clone(),
                slug: slug_for_path(&entry.document.path),
                fingerprint: fingerprint_document(
                    root,
                    &entry.document,
                    &shared_assets_fingerprint,
                )?,
                parent_path: entry.parent_path.clone(),
                hierarchy_order: entry.order,
            })
        })
        .collect()
}

pub(crate) fn fingerprint_document(
    root: &Path,
    document: &Document,
    shared_assets_fingerprint: &str,
) -> AppResult<String> {
    let path = root.join(&document.path);
    let bytes = fs::read(&path).map_err(|err| {
        AppError::new(
            "FILE_READ_ERROR",
            format!("failed to read {}: {err}", path.display()),
        )
    })?;
    let mut hasher = blake3::Hasher::new();
    hasher.update(b"conpub-document-v2\0");
    hasher.update(document.path.as_bytes());
    hasher.update(b"\0");
    hasher.update(&bytes);
    hasher.update(b"\0shared-assets\0");
    hasher.update(shared_assets_fingerprint.as_bytes());

    Ok(hasher.finalize().to_hex().to_string())
}

#[derive(Debug, Clone)]
pub(crate) struct DocumentAsset {
    pub(crate) absolute: PathBuf,
    pub(crate) relative: String,
}

pub(crate) fn fingerprint_shared_assets(root: &Path) -> AppResult<String> {
    let mut hasher = blake3::Hasher::new();
    hasher.update(b"conpub-shared-assets-v1\0");

    for asset in shared_asset_files(root)? {
        let asset_bytes = fs::read(&asset.absolute).map_err(|err| {
            AppError::new(
                "FILE_READ_ERROR",
                format!("failed to read {}: {err}", asset.absolute.display()),
            )
        })?;
        hasher.update(asset.relative.as_bytes());
        hasher.update(b"\0");
        hasher.update(&asset_bytes);
        hasher.update(b"\0");
    }

    Ok(hasher.finalize().to_hex().to_string())
}

pub(crate) fn shared_asset_files(root: &Path) -> AppResult<Vec<DocumentAsset>> {
    let assets_root = root.join(SHARED_ASSETS_DIR);
    if !assets_root.exists() {
        return Ok(Vec::new());
    }
    if !assets_root.is_dir() {
        return Err(AppError::new(
            "ASSETS_ROOT_NOT_DIRECTORY",
            format!(
                "shared assets path is not a directory: {}",
                assets_root.display()
            ),
        ));
    }

    let mut assets = Vec::new();

    for entry in WalkDir::new(&assets_root)
        .into_iter()
        .filter_entry(is_safe_asset_tree_entry)
    {
        let entry = entry.map_err(|err| AppError::new("STAGE_READ_ERROR", err.to_string()))?;
        if !entry.file_type().is_file() || !is_safe_publish_asset(entry.path()) {
            continue;
        }

        let relative = entry.path().strip_prefix(&assets_root).map_err(|_| {
            AppError::new(
                "PATH_OUTSIDE_ROOT",
                format!(
                    "asset is outside shared assets root: {}",
                    entry.path().display()
                ),
            )
        })?;
        assets.push(DocumentAsset {
            absolute: entry.path().to_path_buf(),
            relative: path_to_slash(relative),
        });
    }

    assets.sort_by(|a, b| a.relative.cmp(&b.relative));
    Ok(assets)
}

fn is_safe_asset_tree_entry(entry: &DirEntry) -> bool {
    !is_ignored(entry)
        && entry
            .file_name()
            .to_str()
            .map(is_safe_asset_component)
            .unwrap_or(false)
}

fn is_safe_publish_asset(path: &Path) -> bool {
    !is_supported_doc(path)
        && path
            .file_name()
            .and_then(|name| name.to_str())
            .map(is_safe_asset_component)
            .unwrap_or(false)
        && path
            .extension()
            .and_then(|ext| ext.to_str())
            .map(|ext| {
                matches!(
                    ext.to_ascii_lowercase().as_str(),
                    "png"
                        | "jpg"
                        | "jpeg"
                        | "gif"
                        | "svg"
                        | "webp"
                        | "avif"
                        | "bmp"
                        | "ico"
                        | "pdf"
                )
            })
            .unwrap_or(false)
}

fn is_safe_asset_component(name: &str) -> bool {
    let name = name.to_ascii_lowercase();
    !name.starts_with('.')
        && !matches!(
            name.as_str(),
            "id_rsa"
                | "id_dsa"
                | "id_ecdsa"
                | "id_ed25519"
                | "kubeconfig"
                | "credentials"
                | "credentials.json"
                | "service-account.json"
        )
}

pub(crate) fn stage_shared_assets(root: &Path, post_dir: &Path) -> AppResult<Vec<PathBuf>> {
    let assets = shared_asset_files(root)?;
    if assets.is_empty() {
        return Ok(Vec::new());
    }

    let assets_dir = post_dir.join(STAGED_ASSETS_DIR);
    fs::create_dir_all(&assets_dir).map_err(|err| {
        AppError::new(
            "STAGE_WRITE_ERROR",
            format!("failed to create {}: {err}", assets_dir.display()),
        )
    })?;

    let mut staged = Vec::new();
    for asset in assets {
        let destination = assets_dir.join(&asset.relative);
        if let Some(parent) = destination.parent() {
            fs::create_dir_all(parent).map_err(|err| {
                AppError::new(
                    "STAGE_WRITE_ERROR",
                    format!("failed to create {}: {err}", parent.display()),
                )
            })?;
        }
        link_or_copy_path(&asset.absolute, &destination)?;
        staged.push(destination);
    }

    Ok(staged)
}

pub(crate) fn link_or_copy_path(source: &Path, destination: &Path) -> AppResult<()> {
    #[cfg(unix)]
    {
        if std::os::unix::fs::symlink(source, destination).is_ok() {
            return Ok(());
        }
    }

    copy_path(source, destination)
}

pub(crate) fn copy_path(source: &Path, destination: &Path) -> AppResult<()> {
    fs::copy(source, destination).map(|_| ()).map_err(|err| {
        AppError::new(
            "STAGE_WRITE_ERROR",
            format!(
                "failed to copy {} to {}: {err}",
                source.display(),
                destination.display()
            ),
        )
    })
}