gobby-wiki 0.2.0

Gobby wiki CLI shell
use std::fmt;
use std::io::Write;
use std::path::PathBuf;

use clap::ValueEnum;

use crate::{CommandResult, ScopeIdentity};

#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Format {
    Json,
    Text,
}

#[derive(Debug)]
pub enum OutputError {
    Io(std::io::Error),
    Json(serde_json::Error),
}

impl fmt::Display for OutputError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io(error) => write!(f, "output write failed: {error}"),
            Self::Json(error) => write!(f, "JSON rendering failed: {error}"),
        }
    }
}

impl std::error::Error for OutputError {}

impl From<std::io::Error> for OutputError {
    fn from(error: std::io::Error) -> Self {
        Self::Io(error)
    }
}

impl From<serde_json::Error> for OutputError {
    fn from(error: serde_json::Error) -> Self {
        Self::Json(error)
    }
}

pub fn print_result(
    mut writer: impl Write,
    format: Format,
    result: &CommandResult,
) -> Result<(), OutputError> {
    match format {
        Format::Json => print_json(&mut writer, &result.payload),
        Format::Text => print_text(&mut writer, &result.text),
    }
}

pub fn print_json<T: serde::Serialize + ?Sized>(
    writer: &mut impl Write,
    value: &T,
) -> Result<(), OutputError> {
    writeln!(writer, "{}", serde_json::to_string_pretty(value)?)?;
    Ok(())
}

pub fn print_text(writer: &mut impl Write, text: &str) -> Result<(), OutputError> {
    writeln!(writer, "{text}")?;
    Ok(())
}

pub fn print_status(message: &str) {
    eprintln!("gwiki: {message}");
}

#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct SearchOutput {
    pub command: &'static str,
    pub scope: ScopeIdentity,
    pub query: String,
    pub limit: usize,
    pub results: Vec<SearchResultOutput>,
    pub degradations: Vec<String>,
}

impl SearchOutput {
    pub fn new(
        scope: ScopeIdentity,
        query: impl Into<String>,
        limit: usize,
        results: Vec<SearchResultOutput>,
        degradations: Vec<String>,
    ) -> Self {
        Self {
            command: "search",
            scope,
            query: query.into(),
            limit,
            results,
            degradations,
        }
    }
}

#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct AskOutput {
    pub command: &'static str,
    pub scope: ScopeIdentity,
    pub query: String,
    pub status: &'static str,
    pub hits: Vec<SearchResultOutput>,
    pub related_pages: Vec<AskRelatedPageOutput>,
    pub sources: Vec<String>,
    pub gaps: Vec<String>,
    pub stale_candidates: Vec<String>,
    pub suggested_questions: Vec<String>,
    pub warnings: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ai: Option<AskAiOutput>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub synthesis: Option<AskSynthesisOutput>,
}

#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct SearchResultOutput {
    pub title: Option<String>,
    pub wiki_page: PathBuf,
    pub source_path: PathBuf,
    pub snippet: String,
    pub score: f64,
    pub sources: Vec<String>,
    pub explanations: Vec<SearchSourceExplanationOutput>,
}

#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct AskRelatedPageOutput {
    pub title: Option<String>,
    pub path: PathBuf,
    pub score: f64,
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct AskAiOutput {
    pub requested: bool,
    pub requested_mode: &'static str,
    pub route: &'static str,
    pub status: &'static str,
    pub model: Option<String>,
    pub error: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct AskSynthesisOutput {
    pub answer: String,
    pub model: Option<String>,
}

#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct SearchSourceExplanationOutput {
    pub source: String,
    pub rank: usize,
    pub score: f64,
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct QueryOutput {
    pub command: &'static str,
    pub scope: ScopeIdentity,
    pub query: String,
    pub answer: String,
    pub citations: Vec<QueryCitation>,
}

impl QueryOutput {
    pub fn answered(
        scope: ScopeIdentity,
        query: impl Into<String>,
        answer: impl Into<String>,
        citations: Vec<QueryCitation>,
    ) -> Self {
        Self {
            command: "query",
            scope,
            query: query.into(),
            answer: answer.into(),
            citations,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct QueryCitation {
    pub source_path: PathBuf,
    pub wiki_page: PathBuf,
    pub title: Option<String>,
    pub lines: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct AuditOutput {
    pub command: &'static str,
    pub scope: ScopeIdentity,
    pub unsupported_claim_count: usize,
    pub report_path: Option<PathBuf>,
    pub source_paths: Vec<PathBuf>,
}

impl AuditOutput {
    pub fn new(
        scope: ScopeIdentity,
        unsupported_claim_count: usize,
        report_path: Option<PathBuf>,
        source_paths: Vec<PathBuf>,
    ) -> Self {
        Self {
            command: "audit",
            scope,
            unsupported_claim_count,
            report_path,
            source_paths,
        }
    }
}

pub fn render_query_text(output: &QueryOutput) -> String {
    let mut text = format!(
        "Query answer\nScope: {}\nQuestion: {}\nAnswer: {}\n",
        output.scope, output.query, output.answer
    );
    if output.citations.is_empty() {
        text.push_str("Citations: none\n");
        return text;
    }

    text.push_str("Citations:\n");
    for citation in &output.citations {
        text.push_str("- Source: ");
        text.push_str(&citation.source_path.display().to_string());
        text.push_str(" | Wiki: ");
        text.push_str(&citation.wiki_page.display().to_string());
        if let Some(title) = &citation.title {
            text.push_str(" | Title: ");
            text.push_str(title);
        }
        if let Some(lines) = &citation.lines {
            text.push_str(" | Lines: ");
            text.push_str(lines);
        }
        text.push('\n');
    }
    text
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    #[test]
    fn json_output_is_stable() {
        let scope = ScopeIdentity::topic("rust");
        let search = SearchOutput::new(
            scope.clone(),
            "ownership",
            2,
            vec![SearchResultOutput {
                title: Some("Ownership".to_string()),
                wiki_page: "wiki/topics/ownership.md".into(),
                source_path: "raw/INDEX.md".into(),
                snippet: "Ownership rules move values.".to_string(),
                score: 0.91,
                sources: vec!["bm25".to_string()],
                explanations: vec![SearchSourceExplanationOutput {
                    source: "bm25".to_string(),
                    rank: 1,
                    score: 0.016666666666666666,
                }],
            }],
            vec!["semantic_unavailable".to_string()],
        );
        let query = QueryOutput::answered(
            scope.clone(),
            "How does ownership work?",
            "Ownership controls value moves.",
            vec![QueryCitation {
                source_path: "raw/INDEX.md".into(),
                wiki_page: "wiki/topics/ownership.md".into(),
                title: Some("Ownership".to_string()),
                lines: Some("4-8".to_string()),
            }],
        );
        let audit = AuditOutput::new(
            scope,
            1,
            Some("outputs/audit-20260529.json".into()),
            vec!["raw/INDEX.md".into()],
        );

        assert_eq!(
            serde_json::to_value(&search).expect("search JSON"),
            json!({
                "command": "search",
                "scope": {"kind": "topic", "id": "rust"},
                "query": "ownership",
                "limit": 2,
                "results": [{
                    "title": "Ownership",
                    "wiki_page": "wiki/topics/ownership.md",
                    "source_path": "raw/INDEX.md",
                    "snippet": "Ownership rules move values.",
                    "score": 0.91,
                    "sources": ["bm25"],
                    "explanations": [{
                        "source": "bm25",
                        "rank": 1,
                        "score": 0.016666666666666666
                    }]
                }],
                "degradations": ["semantic_unavailable"]
            })
        );
        assert_eq!(
            serde_json::to_value(&query).expect("query JSON"),
            json!({
                "command": "query",
                "scope": {"kind": "topic", "id": "rust"},
                "query": "How does ownership work?",
                "answer": "Ownership controls value moves.",
                "citations": [{
                    "source_path": "raw/INDEX.md",
                    "wiki_page": "wiki/topics/ownership.md",
                    "title": "Ownership",
                    "lines": "4-8"
                }]
            })
        );
        assert_eq!(
            serde_json::to_value(&audit).expect("audit JSON"),
            json!({
                "command": "audit",
                "scope": {"kind": "topic", "id": "rust"},
                "unsupported_claim_count": 1,
                "report_path": "outputs/audit-20260529.json",
                "source_paths": ["raw/INDEX.md"]
            })
        );
    }

    #[test]
    fn query_output_includes_citations() {
        let query = QueryOutput::answered(
            ScopeIdentity::project("project-123"),
            "Which page explains ownership?",
            "See the Ownership page.",
            vec![QueryCitation {
                source_path: "raw/rust-book.md".into(),
                wiki_page: "wiki/topics/ownership.md".into(),
                title: Some("Ownership".to_string()),
                lines: Some("12-21".to_string()),
            }],
        );

        let citation = query.citations.first().expect("citation");
        assert_eq!(
            citation.source_path,
            std::path::PathBuf::from("raw/rust-book.md")
        );
        assert_eq!(
            citation.wiki_page,
            std::path::PathBuf::from("wiki/topics/ownership.md")
        );

        let rendered = render_query_text(&query);
        assert!(rendered.contains("Source: raw/rust-book.md"));
        assert!(rendered.contains("Wiki: wiki/topics/ownership.md"));
    }
}