gobby-wiki 0.7.0

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

use crate::WikiError;
use crate::sources::SourceRecord;

pub(crate) fn validate_source_id(id: &str) -> Result<&str, WikiError> {
    let id = id.trim();
    if id.is_empty()
        || id.contains("..")
        || 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(id)
}

pub(crate) fn raw_source_path(id: &str) -> Result<PathBuf, WikiError> {
    let id = validate_source_id(id)?;
    Ok(Path::new("raw").join(format!("{id}.md")))
}

pub(crate) fn derived_markdown_path(record: &SourceRecord) -> Result<PathBuf, WikiError> {
    let id = validate_source_id(&record.id)?;
    Ok(PathBuf::from("knowledge")
        .join("sources")
        .join(format!("{id}.md")))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sources::{CompileStatus, IngestionMethod, SourceKind};

    #[test]
    fn source_paths_trim_safe_ids() {
        assert_eq!(
            raw_source_path("  src-abc  ").expect("raw path"),
            PathBuf::from("raw/src-abc.md")
        );
    }

    #[test]
    fn source_paths_reject_unsafe_ids() {
        for id in ["", ".", "..", "src/abc", r"src\abc", "src..abc"] {
            let error = raw_source_path(id).expect_err("unsafe source id rejected");
            assert_eq!(error.code(), "invalid_input");
        }
    }

    #[test]
    fn derived_markdown_path_rejects_unsafe_source_ids() {
        let mut record = source_record("src/abc");

        let error = derived_markdown_path(&record).expect_err("unsafe derived path rejected");
        assert_eq!(error.code(), "invalid_input");

        record.id = "src-abc".to_string();
        assert_eq!(
            derived_markdown_path(&record).expect("derived path"),
            PathBuf::from("knowledge/sources/src-abc.md")
        );
    }

    fn source_record(id: &str) -> SourceRecord {
        SourceRecord {
            id: id.to_string(),
            location: "https://example.test/source".to_string(),
            canonical_location: "https://example.test/source".to_string(),
            kind: SourceKind::Url,
            fetched_at: "2026-01-01T00:00:00Z".to_string(),
            content_hash: "hash".to_string(),
            title: None,
            citation: None,
            license: None,
            ingestion_method: IngestionMethod::Manual,
            compile_status: CompileStatus::Pending,
            replay: None,
        }
    }
}