gobby-wiki 0.2.0

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

use crate::{ScopeIdentity, WikiError};

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ExportKind {
    Bundle,
    Report,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportRequest {
    pub filename: String,
    pub kind: ExportKind,
    pub contents: String,
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ExportArtifact {
    pub path: PathBuf,
    pub kind: ExportKind,
    pub bytes_written: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExportCommand {
    WorkflowAssets {
        filename: String,
    },
    ReportFile {
        filename: String,
        source_path: PathBuf,
    },
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ExportOutput {
    pub command: &'static str,
    pub scope: ScopeIdentity,
    pub artifacts: Vec<ExportArtifact>,
}

impl ExportOutput {
    pub fn new(scope: ScopeIdentity, artifacts: Vec<ExportArtifact>) -> Self {
        Self {
            command: "export",
            scope,
            artifacts,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorkflowAsset {
    pub name: &'static str,
    pub filename: &'static str,
    pub contents: &'static str,
}

const WORKFLOW_ASSETS: &[WorkflowAsset] = &[
    WorkflowAsset {
        name: "research",
        filename: "research.md",
        contents: include_str!("../assets/skills/research.md"),
    },
    WorkflowAsset {
        name: "compile",
        filename: "compile.md",
        contents: include_str!("../assets/skills/compile.md"),
    },
    WorkflowAsset {
        name: "query",
        filename: "query.md",
        contents: include_str!("../assets/skills/query.md"),
    },
    WorkflowAsset {
        name: "audit",
        filename: "audit.md",
        contents: include_str!("../assets/skills/audit.md"),
    },
];

pub fn bundled_workflow_assets() -> &'static [WorkflowAsset] {
    WORKFLOW_ASSETS
}

pub fn run(root: &Path, command: ExportCommand) -> Result<Vec<ExportArtifact>, WikiError> {
    match command {
        ExportCommand::WorkflowAssets { filename } => {
            export_workflow_assets(root, filename).map(|artifact| vec![artifact])
        }
        ExportCommand::ReportFile {
            filename,
            source_path,
        } => export_report_file(root, filename, source_path).map(|artifact| vec![artifact]),
    }
}

pub fn export_workflow_assets(
    root: &Path,
    filename: impl Into<String>,
) -> Result<ExportArtifact, WikiError> {
    write_export(
        root,
        ExportRequest {
            filename: filename.into(),
            kind: ExportKind::Bundle,
            contents: workflow_assets_bundle(),
        },
    )
}

pub fn export_report_file(
    root: &Path,
    filename: impl Into<String>,
    source_path: impl AsRef<Path>,
) -> Result<ExportArtifact, WikiError> {
    let source_path = source_path.as_ref();
    let contents = std::fs::read_to_string(source_path).map_err(|error| WikiError::Io {
        action: "read export report",
        path: Some(source_path.to_path_buf()),
        source: error,
    })?;
    write_export(
        root,
        ExportRequest {
            filename: filename.into(),
            kind: ExportKind::Report,
            contents,
        },
    )
}

pub fn write_export(root: &Path, request: ExportRequest) -> Result<ExportArtifact, WikiError> {
    let relative_path = export_relative_path(&request.filename)?;
    let path = root.join("outputs").join(relative_path);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|error| WikiError::Io {
            action: "create export directory",
            path: Some(parent.to_path_buf()),
            source: error,
        })?;
    }

    std::fs::write(&path, &request.contents).map_err(|error| WikiError::Io {
        action: "write export",
        path: Some(path.clone()),
        source: error,
    })?;

    Ok(ExportArtifact {
        path,
        kind: request.kind,
        bytes_written: request.contents.len(),
    })
}

fn export_relative_path(filename: &str) -> Result<PathBuf, WikiError> {
    let path = Path::new(filename);
    if filename.trim().is_empty() || path.is_absolute() {
        return Err(invalid_export_filename(filename));
    }

    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::Normal(part) => normalized.push(part),
            Component::CurDir => {}
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                return Err(invalid_export_filename(filename));
            }
        }
    }

    if normalized.as_os_str().is_empty() {
        return Err(invalid_export_filename(filename));
    }

    Ok(normalized)
}

fn invalid_export_filename(filename: &str) -> WikiError {
    WikiError::InvalidInput {
        field: "filename",
        message: format!("export filename must stay under outputs/: {filename}"),
    }
}

fn workflow_assets_bundle() -> String {
    let mut contents = String::from("# GWiki Workflow Assets\n\n");
    for asset in bundled_workflow_assets() {
        contents.push_str("## ");
        contents.push_str(asset.name);
        contents.push_str("\n\n");
        contents.push_str("Source asset: `");
        contents.push_str(asset.filename);
        contents.push_str("`\n\n");
        contents.push_str(asset.contents.trim_end());
        contents.push_str("\n\n");
    }
    contents
}

#[cfg(test)]
mod tests {
    use std::fs;

    use super::*;

    #[test]
    fn exports_do_not_mutate_wiki_state() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        let wiki_page = root.join("wiki/topics/ownership.md");
        fs::create_dir_all(wiki_page.parent().expect("wiki parent")).expect("wiki dir");
        fs::write(&wiki_page, "# Ownership\n\nCanonical page.\n").expect("wiki page");
        let before = fs::read_to_string(&wiki_page).expect("read before");

        let artifact = write_export(
            root,
            ExportRequest {
                filename: "ownership-bundle.md".to_string(),
                kind: ExportKind::Bundle,
                contents: "# Ownership Bundle\n\nGenerated export.\n".to_string(),
            },
        )
        .expect("export is written");

        assert_eq!(artifact.path, root.join("outputs/ownership-bundle.md"));
        assert_eq!(artifact.bytes_written, 38);
        assert_eq!(fs::read_to_string(&wiki_page).expect("read after"), before);
        assert_eq!(
            fs::read_to_string(root.join("outputs/ownership-bundle.md")).expect("export"),
            "# Ownership Bundle\n\nGenerated export.\n"
        );
        assert_eq!(
            bundled_workflow_assets()
                .iter()
                .map(|asset| asset.name)
                .collect::<Vec<_>>(),
            vec!["research", "compile", "query", "audit"]
        );

        let bundle_artifact =
            export_workflow_assets(root, "workflow-assets.md").expect("workflow assets export");
        assert_eq!(
            bundle_artifact.path,
            root.join("outputs/workflow-assets.md")
        );
        let bundle = fs::read_to_string(root.join("outputs/workflow-assets.md")).expect("bundle");
        assert!(bundle.contains("# GWiki Workflow Assets"));
        assert!(bundle.contains("## research"));

        let report = root.join("meta/health/latest.md");
        fs::create_dir_all(report.parent().expect("report parent")).expect("report dir");
        fs::write(&report, "# Health\n\nGenerated report.\n").expect("report");
        let report_artifact =
            export_report_file(root, "reports/health.md", &report).expect("report export");
        assert_eq!(report_artifact.path, root.join("outputs/reports/health.md"));
        assert_eq!(
            fs::read_to_string(root.join("outputs/reports/health.md")).expect("report export"),
            "# Health\n\nGenerated report.\n"
        );
        assert_eq!(fs::read_to_string(&wiki_page).expect("read final"), before);
    }
}