gobby-wiki 0.7.0

Gobby wiki CLI shell
use std::fs;
use std::path::PathBuf;

use crate::WikiError;
use crate::explainer::ExplainerGeneration;

use super::paths::source_page_paths;
use super::render::yaml_scalar;
use super::{
    ArticleKind, PageWriteKind, SynthesisInput, SynthesisSource, SynthesizedPage, WritePolicy,
    slugify_unique, synthesize_article, write_synthesized_page,
};

#[test]
fn existing_page_requires_merge_intent() {
    let temp = tempfile::tempdir().expect("tempdir");
    let page_path = temp.path().join("knowledge/topics/existing.md");
    std::fs::create_dir_all(page_path.parent().expect("page parent")).expect("create parent");
    std::fs::write(&page_path, "human-authored page").expect("existing page written");

    let page = SynthesizedPage {
        path: page_path.clone(),
        title: "Existing".to_string(),
        markdown: "---\ntitle: Existing\n---\n# Existing\nNew synthesis.\n".to_string(),
        explainer: None,
    };

    let error = write_synthesized_page(temp.path(), &page, WritePolicy::RequireMergeIntent)
        .expect_err("existing page requires merge intent");

    assert!(matches!(
        error,
        crate::WikiError::InvalidInput {
            field: "write_intent",
            ..
        }
    ));
    assert_eq!(
        std::fs::read_to_string(&page_path).expect("page retained"),
        "human-authored page"
    );
}

#[test]
fn synthesized_page_write_classifies_create_and_overwrite_atomically() {
    let temp = tempfile::tempdir().expect("tempdir");
    let page_path = temp.path().join("knowledge/topics/new.md");
    let page = SynthesizedPage {
        path: page_path.clone(),
        title: "New".to_string(),
        markdown: "# New\n".to_string(),
        explainer: None,
    };

    let created = write_synthesized_page(temp.path(), &page, WritePolicy::AllowOverwriteAfterMerge)
        .expect("create synthesized page");
    let overwritten =
        write_synthesized_page(temp.path(), &page, WritePolicy::AllowOverwriteAfterMerge)
            .expect("overwrite synthesized page");

    assert_eq!(created.kind, PageWriteKind::Created);
    assert_eq!(overwritten.kind, PageWriteKind::Overwritten);
    assert_eq!(
        std::fs::read_to_string(&page_path).expect("page written"),
        "# New\n"
    );
}

#[test]
fn slugify_unique_falls_back_after_bounded_suffixes() {
    let slug = slugify_unique("Collision", |_| true);

    assert!(slug.starts_with("collision-"));
    assert!(slug.len() > "collision-".len());
}

#[test]
fn source_page_paths_reserve_article_path() {
    let temp = tempfile::tempdir().expect("tempdir");
    let article_path = temp.path().join("knowledge/sources/collision.md");
    let sources = vec![SynthesisSource {
        title: "Collision".to_string(),
        path: PathBuf::from("raw/collision.md"),
        chunks: Vec::new(),
    }];

    let paths = source_page_paths(temp.path(), &article_path, &sources);

    assert_ne!(paths[0], article_path);
    assert!(paths[0].starts_with(temp.path().join("knowledge/sources")));
}

#[test]
fn synthesized_article_rejects_escaping_target_path() {
    let temp = tempfile::tempdir().expect("tempdir");
    let input = SynthesisInput {
        handoff_id: "handoff-1".to_string(),
        topic: "Escape".to_string(),
        outline: vec![],
        target_kind: ArticleKind::Topic,
        accepted_sources: vec![],
        citations: vec![],
        conflicting_claims: vec![],
        missing_evidence: vec![],
    };
    let outside_name = format!(
        "{}-outside.md",
        temp.path()
            .file_name()
            .and_then(|value| value.to_str())
            .unwrap_or("synthesis")
    );
    let target = temp.path().join("..").join(outside_name);

    let error = synthesize_article(
        temp.path(),
        &input,
        Some(target),
        &ExplainerGeneration::Skipped,
    )
    .expect_err("escaping target must be rejected");

    assert!(matches!(
        error,
        WikiError::InvalidInput {
            field: "article_path",
            ..
        }
    ));
}

#[test]
fn synthesized_writer_rejects_escaping_page_path_before_write() {
    let temp = tempfile::tempdir().expect("tempdir");
    let outside_name = format!(
        "{}-outside.md",
        temp.path()
            .file_name()
            .and_then(|value| value.to_str())
            .unwrap_or("synthesis")
    );
    let outside = temp.path().join("..").join(outside_name);
    let page = SynthesizedPage {
        path: outside.clone(),
        title: "Outside".to_string(),
        markdown: "# Outside\n".to_string(),
        explainer: None,
    };

    let error = write_synthesized_page(temp.path(), &page, WritePolicy::AllowOverwriteAfterMerge)
        .expect_err("escaping page must be rejected");

    assert!(matches!(
        error,
        WikiError::InvalidInput {
            field: "synthesized_page",
            ..
        }
    ));
    assert!(!outside.exists());
}

#[test]
#[cfg(unix)]
fn synthesized_writer_rejects_symlinked_parent_before_create_dir_all() {
    use std::os::unix::fs::symlink;

    let temp = tempfile::tempdir().expect("tempdir");
    let outside = tempfile::tempdir().expect("outside tempdir");
    let link = temp.path().join("wiki").join("linked");
    fs::create_dir_all(link.parent().expect("link parent")).expect("link parent");
    symlink(outside.path(), &link).expect("symlink parent");
    let page = SynthesizedPage {
        path: link.join("nested/page.md"),
        title: "Outside".to_string(),
        markdown: "# Outside\n".to_string(),
        explainer: None,
    };

    let error = write_synthesized_page(temp.path(), &page, WritePolicy::AllowOverwriteAfterMerge)
        .expect_err("symlinked parent must be rejected");

    assert!(matches!(
        error,
        WikiError::InvalidInput {
            field: "synthesized_page",
            ..
        }
    ));
    assert!(!outside.path().join("nested/page.md").exists());
}

#[test]
fn yaml_scalar_escapes_quoted_control_characters() {
    assert_eq!(yaml_scalar("Plain Title"), "\"Plain Title\"");
    assert_eq!(
        yaml_scalar("a\\b\"c\nd\re\tf"),
        "\"a\\\\b\\\"c\\nd\\re\\tf\""
    );
    assert_eq!(
        yaml_scalar("nul\0del\u{7f}\u{80}"),
        "\"nul\\u0000del\\u007f\\u0080\""
    );
}