#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
serde::Serialize,
serde::Deserialize,
schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum CommandSource {
Bitrouter,
Config,
Agent,
}
#[derive(
Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct CommandRow {
pub name: String,
pub description: String,
pub hint: Option<String>,
pub source: CommandSource,
pub shadowed: bool,
pub unavailable: Option<String>,
}
#[derive(
Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct CommandsReport {
pub received: bool,
pub commands: Vec<CommandRow>,
}
use agent_client_protocol::schema::v1::{AvailableCommand, AvailableCommandInput};
use bitrouter_tui::machine::{Command, PromptCommand};
pub fn commands_report(
bitrouter: &[Command],
config: &[PromptCommand],
agent: &[AvailableCommand],
received: bool,
) -> CommandsReport {
let mut commands: Vec<CommandRow> = bitrouter
.iter()
.map(|command| CommandRow {
name: command.name.to_string(),
description: command.summary.to_string(),
hint: None,
source: CommandSource::Bitrouter,
shadowed: false,
unavailable: command.unavailable.map(str::to_string),
})
.collect();
commands.extend(config.iter().map(|command| CommandRow {
name: command.name.clone(),
description: command.description.clone(),
hint: None,
source: CommandSource::Config,
shadowed: false,
unavailable: None,
}));
commands.extend(agent.iter().map(|command| {
CommandRow {
name: command.name.clone(),
description: command.description.clone(),
hint: hint_of(command),
source: CommandSource::Agent,
shadowed: bitrouter
.iter()
.any(|ours| ours.name == command.name.as_str())
|| config.iter().any(|theirs| theirs.name == command.name),
unavailable: None,
}
}));
CommandsReport { received, commands }
}
fn hint_of(command: &AvailableCommand) -> Option<String> {
match command.input.as_ref()? {
AvailableCommandInput::Unstructured(input) => Some(input.hint.clone()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ours(name: &'static str, unavailable: Option<&'static str>) -> Command {
Command {
name,
action: name,
summary: "does a thing",
unavailable,
}
}
fn theirs(name: &str) -> AvailableCommand {
AvailableCommand::new(name, "the agent's own")
}
#[test]
fn rows_are_grouped_by_source_in_precedence_order() {
let report = commands_report(&[ours("route", None)], &[], &[theirs("compact")], true);
let sources: Vec<_> = report.commands.iter().map(|row| row.source).collect();
assert_eq!(
sources,
[CommandSource::Bitrouter, CommandSource::Agent],
"ours is listed before theirs"
);
assert!(report.commands.iter().all(|row| !row.name.starts_with('/')));
}
#[test]
fn a_clashing_agent_command_is_listed_and_marked_shadowed() {
let report = commands_report(
&[ours("status", None)],
&[],
&[theirs("status"), theirs("compact")],
true,
);
let shadowed: Vec<_> = report
.commands
.iter()
.filter(|row| row.shadowed)
.map(|row| (row.name.as_str(), row.source))
.collect();
assert_eq!(shadowed, [("status", CommandSource::Agent)]);
assert_eq!(report.commands.len(), 3, "nothing is dropped");
}
#[test]
fn an_unavailable_command_carries_its_reason() {
let report = commands_report(&[ours("route", Some("no route control"))], &[], &[], true);
assert_eq!(
report.commands[0].unavailable.as_deref(),
Some("no route control")
);
}
#[test]
fn silence_and_an_empty_list_are_different_answers() {
let said_none = commands_report(&[], &[], &[], true);
let silent = commands_report(&[], &[], &[], false);
assert!(said_none.received && said_none.commands.is_empty());
assert!(!silent.received && silent.commands.is_empty());
let rendered = |report: &CommandsReport| {
String::from_utf8_lossy(
&crate::output::Output::new(crate::output::Format::Human).render_to_vec(report),
)
.to_string()
};
assert!(rendered(&said_none).contains("advertises no commands"));
assert!(rendered(&silent).contains("had not sent"));
}
#[test]
fn the_rendered_view_carries_no_tabs() {
let report = commands_report(
&[ours("route", Some("no route control"))],
&[],
&[theirs("route"), theirs("compact")],
true,
);
let text = String::from_utf8_lossy(
&crate::output::Output::new(crate::output::Format::Human).render_to_vec(&report),
)
.to_string();
assert!(!text.contains('\t'), "{text:?}");
assert!(text.contains("/compact"), "{text:?}");
assert!(text.contains("shadowed"), "{text:?}");
assert!(text.contains("no route control"), "{text:?}");
}
}