gobby-wiki 0.3.0

Gobby wiki CLI shell
use std::fs;
use std::path::{Component, Path, PathBuf};

use crate::WikiError;

pub(crate) fn raw_source_path(id: &str) -> Result<PathBuf, WikiError> {
    let id = id.trim();
    if id.is_empty()
        || id.contains('/')
        || id.contains('\\')
        || Path::new(id)
            .components()
            .any(|component| !matches!(component, Component::Normal(_)))
    {
        return Err(WikiError::InvalidInput {
            field: "source_id",
            message: format!("unsafe source id `{id}`"),
        });
    }
    Ok(Path::new("raw").join(format!("{id}.md")))
}

/// Returns vault-relative raw asset paths whose file stem matches `id`.
///
/// `vault_root` is the vault root and `id` is trimmed before matching. Missing
/// `raw/assets` directories and unmatched IDs return an empty vector. Directory
/// read failures are returned as `WikiError::Io`.
pub(crate) fn source_asset_paths_for_id(
    vault_root: &Path,
    id: &str,
) -> Result<Vec<PathBuf>, WikiError> {
    let id = id.trim();
    let asset_dir = vault_root.join("raw/assets");
    if !asset_dir.exists() {
        return Ok(Vec::new());
    }
    let mut paths = Vec::new();
    for entry in fs::read_dir(&asset_dir).map_err(|error| WikiError::Io {
        action: "read raw source assets",
        path: Some(asset_dir.clone()),
        source: error,
    })? {
        let entry = entry.map_err(|error| WikiError::Io {
            action: "read raw source asset entry",
            path: Some(asset_dir.clone()),
            source: error,
        })?;
        let file_name = entry.file_name();
        if file_name.to_str().is_some_and(|name| {
            let path = Path::new(name);
            path.file_stem().and_then(|stem| stem.to_str()) == Some(id)
                && path
                    .extension()
                    .and_then(|extension| extension.to_str())
                    .is_some_and(|extension| !extension.is_empty())
        }) {
            paths.push(Path::new("raw/assets").join(file_name));
        }
    }
    Ok(paths)
}

pub(crate) fn remove_relative_file(
    vault_root: &Path,
    relative_path: &Path,
) -> Result<bool, WikiError> {
    let relative_path = safe_refresh_relative_path(relative_path)?;
    let full_path = vault_root.join(relative_path);
    match fs::remove_file(&full_path) {
        Ok(()) => Ok(true),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(error) => Err(WikiError::Io {
            action: "remove superseded raw source path",
            path: Some(full_path),
            source: error,
        }),
    }
}

fn safe_refresh_relative_path(relative_path: &Path) -> Result<PathBuf, WikiError> {
    if relative_path.as_os_str().is_empty() {
        return Err(WikiError::InvalidInput {
            field: "relative_path",
            message: "path must be a non-empty vault-relative path".to_string(),
        });
    }

    let mut normalized = PathBuf::new();
    for component in relative_path.components() {
        match component {
            Component::Normal(part) => normalized.push(part),
            Component::CurDir => {}
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                return Err(WikiError::InvalidInput {
                    field: "relative_path",
                    message: format!(
                        "path `{}` must stay inside the vault",
                        relative_path.display()
                    ),
                });
            }
        }
    }

    if normalized.as_os_str().is_empty() {
        return Err(WikiError::InvalidInput {
            field: "relative_path",
            message: "path must include a file name".to_string(),
        });
    }

    Ok(normalized)
}

pub(crate) fn ensure_scope_root(root: &Path) -> Result<(), WikiError> {
    if root.is_dir() {
        Ok(())
    } else {
        Err(WikiError::NotFound {
            resource: "wiki scope",
            id: root.display().to_string(),
        })
    }
}