use anyhow::{Context, Result};
use clap::CommandFactory;
use serde::Serialize;
use std::borrow::Cow;
use unicode_width::UnicodeWidthStr;
use crate::cli::Cli;
use crate::cli_l10n::localize_command;
use crate::json_envelope::{GeneratorInfo, SCHEMA_VERSION};
use crate::localization::{self, keys};
use crate::output_mode;
use crate::output_prefs::{self, OutputPrefs};
use crate::status::{LocalizationKey, PipelineStage, StatusReporter, report_pipeline_stage};
use crate::theme::ThemeContext;
use super::process;
use telemetry::instrument_help_targets;
#[path = "help_query.rs"]
mod query;
#[path = "help_telemetry.rs"]
mod telemetry;
#[cfg(test)]
use query::build_catalogue;
use query::{HelpEntry, HelpTargetsQueryFailure, query_help_targets};
pub(super) fn handle_help_targets(cli: &Cli, reporter: &dyn StatusReporter) -> Result<()> {
let query =
match instrument_help_targets(|| query_help_targets(cli).map_err(anyhow::Error::new)) {
Ok(query) => query,
Err(error) => {
report_query_failure_stages(reporter, &error);
return Err(error);
}
};
report_query_stages(reporter, &query.stages);
report_pipeline_stage(reporter, PipelineStage::IrGenerationValidation, None);
let status_key: LocalizationKey = keys::STATUS_TOOL_HELP_TARGETS.into();
report_pipeline_stage(reporter, PipelineStage::GraphRendering, Some(status_key));
if cli.json {
let rendered = render_json(&query.entries)?;
process::write_text_stdout(&rendered)?;
} else {
let rendered = render_text(&query.entries, resolved_prefs(cli));
process::write_text_stdout(&rendered)?;
}
reporter.report_complete(status_key);
Ok(())
}
fn report_query_stages(reporter: &dyn StatusReporter, stages: &[PipelineStage]) {
for stage in stages {
report_pipeline_stage(reporter, *stage, None);
}
}
fn report_query_failure_stages(reporter: &dyn StatusReporter, error: &anyhow::Error) {
if let Some(failure) = error.downcast_ref::<HelpTargetsQueryFailure>() {
report_query_stages(reporter, &failure.stages);
}
}
pub(super) fn render_root_help() -> Result<()> {
let localizer = localization::localizer();
let mut command = localize_command(Cli::command(), localizer.as_ref());
let text = command.render_long_help().to_string();
process::write_text_stdout(&text)
}
pub(super) fn render_subcommand_help(name: &str) -> Result<()> {
let localizer = localization::localizer();
let mut command = localize_command(Cli::command(), localizer.as_ref());
let subcommand = command
.find_subcommand_mut(name)
.with_context(|| format!("unknown subcommand '{name}'"))?;
let text = subcommand.render_long_help().to_string();
process::write_text_stdout(&text)
}
fn resolved_prefs(cli: &Cli) -> OutputPrefs {
let mode = output_mode::resolve(cli.accessibility_override(), Some(cli.color));
output_prefs::resolve_from_theme(
cli.theme_preference(),
ThemeContext::new(None, Some(cli.color), mode),
)
}
fn render_text(entries: &[HelpEntry], prefs: OutputPrefs) -> String {
let actions: Vec<&HelpEntry> = entries.iter().filter(|entry| entry.is_action).collect();
let targets: Vec<&HelpEntry> = entries.iter().filter(|entry| !entry.is_action).collect();
let mut out = String::new();
render_section(&mut out, &actions, keys::CLI_HELP_ACTIONS_HEADING, prefs);
if !actions.is_empty() && !targets.is_empty() {
out.push('\n');
}
render_section(&mut out, &targets, keys::CLI_HELP_TARGETS_HEADING, prefs);
out
}
fn render_section(
out: &mut String,
entries: &[&HelpEntry],
heading_key: &'static str,
prefs: OutputPrefs,
) {
if entries.is_empty() {
return;
}
out.push_str(&localization::message(heading_key).to_string());
out.push('\n');
let display_names: Vec<Cow<'_, str>> = entries
.iter()
.map(|entry| terminal_safe(&entry.name))
.collect();
let width = display_names
.iter()
.map(|name| UnicodeWidthStr::width(name.as_ref()))
.max()
.unwrap_or(0);
let marker = default_marker(prefs);
for (entry, name) in entries.iter().zip(display_names) {
let name_width = UnicodeWidthStr::width(name.as_ref());
out.push_str(" ");
out.push_str(&name);
out.push_str(&" ".repeat(width.saturating_sub(name_width)));
if let Some(description) = entry.description.as_deref() {
out.push_str(" ");
out.push_str(&terminal_safe(description));
}
if entry.is_default {
out.push(' ');
out.push_str(&marker);
}
out.push('\n');
}
}
fn terminal_safe(input: &str) -> Cow<'_, str> {
if !input.chars().any(is_terminal_control) {
return Cow::Borrowed(input);
}
let mut escaped = String::with_capacity(input.len());
for character in input.chars() {
match character {
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
control if is_terminal_control(control) => escaped.extend(control.escape_unicode()),
printable => escaped.push(printable),
}
}
Cow::Owned(escaped)
}
const fn is_terminal_control(character: char) -> bool {
matches!(
character,
'\0'..='\u{001F}'
| '\u{007F}'..='\u{009F}'
| '\u{061C}'
| '\u{200E}'
| '\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2066}'..='\u{2069}'
)
}
fn default_marker(prefs: OutputPrefs) -> String {
let glyph = if prefs.emoji_allowed() { "★" } else { "*" };
let label = localization::message(keys::CLI_HELP_DEFAULT_MARKER).to_string();
format!("[{glyph} {label}]")
}
#[derive(Debug, Serialize)]
struct HelpTargetsDocument<'a> {
schema_version: u32,
generator: GeneratorInfo,
result: HelpTargetsResult<'a>,
}
#[derive(Debug, Serialize)]
struct HelpTargetsResult<'a> {
command: &'static str,
actions: Vec<HelpEntryJson<'a>>,
targets: Vec<HelpEntryJson<'a>>,
}
#[derive(Debug, Serialize)]
struct HelpEntryJson<'a> {
name: &'a str,
description: Option<&'a str>,
default: bool,
}
fn render_json(entries: &[HelpEntry]) -> Result<String> {
serde_json::to_string_pretty(&HelpTargetsDocument {
schema_version: SCHEMA_VERSION,
generator: GeneratorInfo::current(),
result: HelpTargetsResult {
command: "help-targets",
actions: json_entries(entries.iter().filter(|entry| entry.is_action)),
targets: json_entries(entries.iter().filter(|entry| !entry.is_action)),
},
})
.context("serialize help targets catalogue")
}
fn json_entries<'entry>(
entries: impl Iterator<Item = &'entry HelpEntry>,
) -> Vec<HelpEntryJson<'entry>> {
entries
.into_iter()
.map(|entry| HelpEntryJson {
name: entry.name.as_str(),
description: entry.description.as_deref(),
default: entry.is_default,
})
.collect()
}
#[cfg(test)]
#[path = "help_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "help_telemetry_tests.rs"]
mod telemetry_tests;