wavepeek 2.1.0

Command-line tool for RTL waveform inspection with deterministic machine-friendly output.
Documentation
use crate::cli::docs::{
    DocsArgs, DocsCommand, DocsExportArgs, DocsSearchArgs, DocsShowArgs, DocsTopicsArgs,
};
use crate::diagnostic::{Diagnostic, WarningDiagnosticCode};
use crate::docs;
use crate::engine::{
    CommandData, CommandName, CommandResult, DocsSearchData, DocsSearchMatchData, DocsTopicsData,
    HumanRenderOptions,
};
use crate::error::WavepeekError;

pub fn run(args: DocsArgs) -> Result<CommandResult, WavepeekError> {
    match args.command {
        None => orientation_index(),
        Some(DocsCommand::Topics(args)) => topics(args),
        Some(DocsCommand::Show(args)) => show(args),
        Some(DocsCommand::Search(args)) => search(args),
        Some(DocsCommand::Export(args)) => export(args),
    }
}

fn orientation_index() -> Result<CommandResult, WavepeekError> {
    Ok(text_result(
        CommandName::Docs,
        "wavepeek local docs\n\nStart here when you need more than command syntax.\n\nTry:\n  wavepeek docs topics\n  wavepeek docs show intro\n  wavepeek docs search transitions\n  wavepeek docs export /tmp/wavepeek-docs\n".to_string(),
    ))
}

fn topics(args: DocsTopicsArgs) -> Result<CommandResult, WavepeekError> {
    let topics = docs::list_topics()?;
    let diagnostics = empty_topics_diagnostics(&topics);
    if args.json {
        return Ok(CommandResult {
            command: CommandName::DocsTopics,
            output_mode: crate::output_mode::OutputMode::Json,
            human_options: HumanRenderOptions::default(),
            data: CommandData::DocsTopics(DocsTopicsData { topics }),
            diagnostics,
        });
    }

    let rendered = topics
        .iter()
        .map(|topic| format!("{}{}", topic.id, topic.description))
        .collect::<Vec<_>>()
        .join("\n");

    Ok(text_result_with_diagnostics(
        CommandName::DocsTopics,
        rendered,
        diagnostics,
    ))
}

fn show(args: DocsShowArgs) -> Result<CommandResult, WavepeekError> {
    let topic = docs::lookup_topic(&args.topic)?.ok_or_else(|| unknown_topic_error(&args.topic))?;
    let rendered = if args.description {
        topic.topic.description.clone()
    } else {
        topic.body.clone()
    };

    Ok(text_result(CommandName::DocsShow, rendered))
}

fn search(args: DocsSearchArgs) -> Result<CommandResult, WavepeekError> {
    let raw_query = args.query.join(" ");
    let normalized_query = docs::normalize_search_query(&raw_query)?;
    let matches = docs::search_topics(&raw_query)?;
    let diagnostics = empty_docs_search_diagnostics(matches.len());

    if args.json {
        return Ok(CommandResult {
            command: CommandName::DocsSearch,
            output_mode: crate::output_mode::OutputMode::Json,
            human_options: HumanRenderOptions::default(),
            data: CommandData::DocsSearch(DocsSearchData {
                query: normalized_query,
                matches: matches
                    .into_iter()
                    .map(|entry| DocsSearchMatchData {
                        topic: entry.topic,
                        match_kind: entry.match_kind,
                        matched_tokens: entry.matched_tokens,
                    })
                    .collect(),
            }),
            diagnostics,
        });
    }

    let rendered = matches
        .iter()
        .map(|entry| {
            format!(
                "{}  {}{} [{}]",
                entry.topic.id,
                entry.topic.title,
                entry.topic.description,
                match_label(entry.match_kind)
            )
        })
        .collect::<Vec<_>>()
        .join("\n");

    Ok(text_result_with_diagnostics(
        CommandName::DocsSearch,
        rendered,
        diagnostics,
    ))
}

fn export(args: DocsExportArgs) -> Result<CommandResult, WavepeekError> {
    let summary = docs::export_catalog(args.out_dir.as_path(), args.force)?;
    let rendered = format!(
        "exported {} topic(s) to {}",
        summary.topics.len(),
        summary.out_dir
    );

    Ok(text_result(CommandName::DocsExport, rendered))
}

fn text_result(command: CommandName, text: String) -> CommandResult {
    text_result_with_diagnostics(command, text, Vec::new())
}

fn text_result_with_diagnostics(
    command: CommandName,
    text: String,
    diagnostics: Vec<Diagnostic>,
) -> CommandResult {
    CommandResult {
        command,
        output_mode: crate::output_mode::OutputMode::Human,
        human_options: HumanRenderOptions::default(),
        data: CommandData::Text(text),
        diagnostics,
    }
}

fn empty_topics_diagnostics(topics: &[crate::docs::TopicSummary]) -> Vec<Diagnostic> {
    if topics.is_empty() {
        vec![Diagnostic::warning(
            WarningDiagnosticCode::EmptyResult,
            "no docs topics available",
        )]
    } else {
        Vec::new()
    }
}

fn empty_docs_search_diagnostics(match_count: usize) -> Vec<Diagnostic> {
    if match_count == 0 {
        vec![Diagnostic::warning(
            WarningDiagnosticCode::EmptyResult,
            "no docs topics matched query",
        )]
    } else {
        Vec::new()
    }
}

fn unknown_topic_error(topic: &str) -> WavepeekError {
    let suggestions = docs::suggest_topics(topic, 3);
    if suggestions.is_empty() {
        return WavepeekError::Args(format!("unknown docs topic '{topic}'"));
    }

    let suggestions = suggestions
        .iter()
        .map(|suggestion| suggestion.id.as_str())
        .collect::<Vec<_>>()
        .join(", ");
    WavepeekError::Args(format!(
        "unknown docs topic '{topic}'. Did you mean: {suggestions}?"
    ))
}

fn match_label(kind: crate::docs::MatchKind) -> &'static str {
    match kind {
        crate::docs::MatchKind::IdExact => "matched id",
        crate::docs::MatchKind::IdPrefix => "matched id prefix",
        crate::docs::MatchKind::TitleExact => "matched title",
        crate::docs::MatchKind::TitleOrDescription => "matched title or description",
        crate::docs::MatchKind::Heading => "matched heading",
        crate::docs::MatchKind::Body => "matched body",
    }
}

#[cfg(test)]
mod tests {
    use tempfile::tempdir;

    use super::{match_label, run, unknown_topic_error};
    use crate::cli::docs::{
        DocsArgs, DocsCommand, DocsExportArgs, DocsSearchArgs, DocsShowArgs, DocsTopicsArgs,
    };
    use crate::docs::MatchKind;
    use crate::engine::{CommandData, CommandName};

    #[test]
    fn run_without_subcommand_returns_orientation_text() {
        let result = run(DocsArgs { command: None }).expect("docs root should succeed");

        assert_eq!(result.command, CommandName::Docs);
        assert!(matches!(
            result.data,
            CommandData::Text(ref text) if text.contains("wavepeek docs topics")
        ));
    }

    #[test]
    fn topics_support_human_and_json_modes() {
        let human = run(DocsArgs {
            command: Some(DocsCommand::Topics(DocsTopicsArgs { json: false })),
        })
        .expect("human topics should succeed");
        assert_eq!(human.command, CommandName::DocsTopics);
        assert!(matches!(
            human.data,
            CommandData::Text(ref text) if text.contains("intro")
        ));

        let json = run(DocsArgs {
            command: Some(DocsCommand::Topics(DocsTopicsArgs { json: true })),
        })
        .expect("json topics should succeed");
        assert!(matches!(
            json.data,
            CommandData::DocsTopics(ref data) if !data.topics.is_empty()
        ));
    }

    #[test]
    fn show_uses_description_flag_and_unknown_topic_suggestions() {
        let description = run(DocsArgs {
            command: Some(DocsCommand::Show(DocsShowArgs {
                topic: "intro".to_string(),
                description: true,
            })),
        })
        .expect("description show should succeed");
        assert!(matches!(
            description.data,
            CommandData::Text(ref text) if !text.contains("# ") && text.contains("wavepeek")
        ));

        let body = run(DocsArgs {
            command: Some(DocsCommand::Show(DocsShowArgs {
                topic: "intro".to_string(),
                description: false,
            })),
        })
        .expect("body show should succeed");
        assert!(matches!(
            body.data,
            CommandData::Text(ref text) if text.starts_with("# ")
        ));

        let error = run(DocsArgs {
            command: Some(DocsCommand::Show(DocsShowArgs {
                topic: "commands/cha".to_string(),
                description: false,
            })),
        })
        .expect_err("unknown topic should fail");
        assert!(error.to_string().contains("Did you mean: commands/change?"));
    }

    #[test]
    fn search_supports_human_and_json_modes() {
        let human = run(DocsArgs {
            command: Some(DocsCommand::Search(DocsSearchArgs {
                query: vec!["change".to_string()],
                json: false,
            })),
        })
        .expect("human search should succeed");
        assert!(matches!(
            human.data,
            CommandData::Text(ref text) if text.contains("[matched")
        ));

        let json = run(DocsArgs {
            command: Some(DocsCommand::Search(DocsSearchArgs {
                query: vec!["Change".to_string(), "command".to_string()],
                json: true,
            })),
        })
        .expect("json search should succeed");
        assert!(matches!(
            json.data,
            CommandData::DocsSearch(ref data)
                if data.query == "change command" && !data.matches.is_empty()
        ));
    }

    #[test]
    fn export_reports_destination_path() {
        let temp = tempdir().expect("tempdir should exist");
        let out_dir = temp.path().join("docs-export");
        let result = run(DocsArgs {
            command: Some(DocsCommand::Export(DocsExportArgs {
                out_dir: out_dir.clone(),
                force: false,
            })),
        })
        .expect("export should succeed");

        assert_eq!(result.command, CommandName::DocsExport);
        assert!(matches!(
            result.data,
            CommandData::Text(ref text)
                if text.contains("exported") && text.contains(out_dir.display().to_string().as_str())
        ));
    }

    #[test]
    fn unknown_topic_error_handles_empty_suggestions() {
        let error = unknown_topic_error("zzzzzzzzzzzz");
        assert_eq!(
            error.to_string(),
            "fatal: args: unknown docs topic 'zzzzzzzzzzzz'"
        );
    }

    #[test]
    fn match_label_exercises_all_search_kinds() {
        assert_eq!(match_label(MatchKind::IdExact), "matched id");
        assert_eq!(match_label(MatchKind::IdPrefix), "matched id prefix");
        assert_eq!(match_label(MatchKind::TitleExact), "matched title");
        assert_eq!(
            match_label(MatchKind::TitleOrDescription),
            "matched title or description"
        );
        assert_eq!(match_label(MatchKind::Heading), "matched heading");
        assert_eq!(match_label(MatchKind::Body), "matched body");
    }
}