update output format

This commit is contained in:
Alexander Kiselev
2026-01-26 07:30:32 -08:00
parent f7b5049625
commit e7d10b599b
18 changed files with 160 additions and 55 deletions
+29 -1
View File
@@ -1 +1,29 @@
See @AGENTS.md
# ghidra-cli Navigation Index
See @AGENTS.md for agent-specific instructions.
## Key Files
| What | When |
|------|------|
| `src/main.rs` | Modifying CLI entry point, daemon lifecycle, or output format detection |
| `src/cli.rs` | Adding/modifying CLI arguments and subcommands |
| `src/format/mod.rs` | Implementing new output formats or changing format detection logic |
| `src/daemon/handlers/*.rs` | Implementing daemon command handlers |
| `PLAN.md` | Understanding current implementation plan or reviewing decision rationale |
| `README.md` | Understanding project architecture or user-facing command documentation |
## Modules
| What | When |
|------|------|
| `src/daemon/` | Working with persistent Ghidra daemon or IPC communication |
| `src/format/` | Handling output format conversion (Table, Compact, JSON, CSV, etc.) |
| `tests/` | Writing integration or unit tests |
## Documentation
| What | When |
|------|------|
| `src/daemon/README.md` | Understanding daemon architecture and IPC protocol |
| `tests/README.md` | Understanding test structure and conventions |
+11 -5
View File
@@ -196,20 +196,26 @@ ghidra daemon restart --project myproject --program otherbinary
## Output Formats
```bash
# Human-readable (default)
ghidra function list
Default output adapts to context:
- **Interactive (TTY)**: Compact human-readable format
- **Piped/scripted**: Compact JSON for machine parsing
# JSON output
Override with flags:
```bash
# Force JSON output (compact, single-line)
ghidra function list --json
# Pretty JSON
# Force pretty JSON (indented, multi-line)
ghidra function list --pretty
# Select specific fields
ghidra function list --fields "name,address,size"
```
### Output Format Design
Format detection occurs at the CLI boundary rather than in daemon handlers. Handlers always return compact JSON for IPC efficiency and caching stability. The CLI applies format transformation (human-readable, pretty JSON) at the output boundary based on TTY detection or explicit flags. This design maintains a stable IPC protocol with a single format decision point, preventing daemon cache invalidation from format variations.
## Filtering
Use expressions to filter results:
+8
View File
@@ -15,6 +15,14 @@ pub struct Cli {
/// Suppress non-essential output
#[arg(short, long, global = true)]
pub quiet: bool,
/// Output as JSON
#[arg(long, global = true)]
pub json: bool,
/// Output JSON with pretty formatting
#[arg(long, global = true)]
pub pretty: bool,
}
#[derive(Subcommand, Clone, Serialize, Deserialize, Debug)]
+1 -1
View File
@@ -40,7 +40,7 @@ pub async fn handle_batch(file_path: &str) -> Result<String> {
"results": results
});
serde_json::to_string_pretty(&response)
serde_json::to_string(&response)
.context("Failed to serialize batch results")
}
+2 -2
View File
@@ -12,7 +12,7 @@ pub async fn handle_comment_list(bridge: &mut GhidraBridge) -> Result<String> {
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to list comments".to_string());
@@ -31,7 +31,7 @@ pub async fn handle_comment_get(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to get comment".to_string());
+2 -2
View File
@@ -16,7 +16,7 @@ pub async fn handle_diff_programs(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to diff programs".to_string());
@@ -36,7 +36,7 @@ pub async fn handle_diff_functions(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to diff functions".to_string());
+1 -1
View File
@@ -22,7 +22,7 @@ pub async fn handle_disasm(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to disassemble".to_string());
+6 -6
View File
@@ -15,7 +15,7 @@ pub async fn handle_find_string(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to find string".to_string());
@@ -34,7 +34,7 @@ pub async fn handle_find_bytes(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to find bytes".to_string());
@@ -53,7 +53,7 @@ pub async fn handle_find_function(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to find function".to_string());
@@ -72,7 +72,7 @@ pub async fn handle_find_calls(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to find calls".to_string());
@@ -88,7 +88,7 @@ pub async fn handle_find_crypto(bridge: &mut GhidraBridge) -> Result<String> {
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to find crypto constants".to_string());
@@ -104,7 +104,7 @@ pub async fn handle_find_interesting(bridge: &mut GhidraBridge) -> Result<String
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to find interesting functions".to_string());
+4 -4
View File
@@ -21,7 +21,7 @@ pub async fn handle_graph_calls(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to get call graph".to_string());
@@ -46,7 +46,7 @@ pub async fn handle_graph_callers(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to get callers".to_string());
@@ -71,7 +71,7 @@ pub async fn handle_graph_callees(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to get callees".to_string());
@@ -90,7 +90,7 @@ pub async fn handle_graph_export(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to export graph".to_string());
+3 -3
View File
@@ -19,7 +19,7 @@ pub async fn handle_patch_bytes(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to patch bytes".to_string());
@@ -38,7 +38,7 @@ pub async fn handle_patch_nop(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to patch NOP".to_string());
@@ -57,7 +57,7 @@ pub async fn handle_patch_export(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to export patched binary".to_string());
+2 -2
View File
@@ -45,7 +45,7 @@ pub async fn handle_program_info(bridge: &mut GhidraBridge) -> Result<String> {
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to get program info".to_string());
@@ -73,7 +73,7 @@ pub async fn handle_program_export(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to export program".to_string());
+4 -4
View File
@@ -16,7 +16,7 @@ pub async fn handle_script_run(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to run script".to_string());
@@ -35,7 +35,7 @@ pub async fn handle_script_python(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to execute Python code".to_string());
@@ -54,7 +54,7 @@ pub async fn handle_script_java(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to execute Java code".to_string());
@@ -70,7 +70,7 @@ pub async fn handle_script_list(bridge: &mut GhidraBridge) -> Result<String> {
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to list scripts".to_string());
+1 -1
View File
@@ -11,7 +11,7 @@ pub async fn handle_stats(bridge: &mut GhidraBridge) -> Result<String> {
if response.status == "success" {
let data = response.data.unwrap_or(serde_json::json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to get program statistics".to_string());
+2 -2
View File
@@ -21,7 +21,7 @@ pub async fn handle_symbol_list(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to list symbols".to_string());
@@ -40,7 +40,7 @@ pub async fn handle_symbol_get(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to get symbol".to_string());
+2 -2
View File
@@ -12,7 +12,7 @@ pub async fn handle_type_list(bridge: &mut GhidraBridge) -> Result<String> {
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to list types".to_string());
@@ -31,7 +31,7 @@ pub async fn handle_type_get(
if response.status == "success" {
let data = response.data.unwrap_or(json!({}));
serde_json::to_string_pretty(&data)
serde_json::to_string(&data)
.context("Failed to serialize response")
} else {
let message = response.message.unwrap_or_else(|| "Failed to get type".to_string());
+2 -1
View File
@@ -91,6 +91,7 @@ impl Formatter for DefaultFormatter {
OutputFormat::Tsv => {
format_csv(data, '\t')
}
OutputFormat::Compact => format_minimal(data),
OutputFormat::Minimal | OutputFormat::Ids => {
format_minimal(data)
}
@@ -232,7 +233,7 @@ fn format_json_value(value: &JsonValue) -> String {
pub fn auto_detect_format(is_tty: bool) -> OutputFormat {
if is_tty {
OutputFormat::Table
OutputFormat::Compact
} else {
OutputFormat::JsonCompact
}
+28 -18
View File
@@ -17,6 +17,8 @@ use error::{GhidraError, Result};
use ghidra::GhidraClient;
use std::path::PathBuf;
use tracing::info;
use atty;
use format::{auto_detect_format, DefaultFormatter, Formatter, OutputFormat};
#[tokio::main]
@@ -121,27 +123,18 @@ async fn run_with_daemon_check(cli: Cli) -> anyhow::Result<()> {
}
let config = Config::load()?;
let project_path = match &cli.command {
Commands::Import(args) => {
resolve_project_path(&args.project, &config)?
}
Commands::Analyze(args) => {
resolve_project_path(&args.project, &config)?
}
Commands::Quick(args) => {
resolve_project_path(&args.project, &config)?
}
_ => {
resolve_project_path(&None, &config)?
}
};
ensure_daemon_running(&project_path).await?;
// Extract project and program from command arguments
let (project_opt, program_opt) = extract_project_program(&cli.command);
let project_path = resolve_project_path(&project_opt, &config)?;
let program_name = program_opt.or(config.default_program.clone());
ensure_daemon_running(&project_path, program_name.as_deref()).await?;
match ipc::client::DaemonClient::connect().await {
Ok(mut client) => {
info!("Connected to daemon via IPC");
let output = execute_via_daemon(&mut client, &cli.command).await?;
let output = execute_via_daemon(&mut client, &cli.command, cli.json, cli.pretty).await?;
if !output.is_empty() {
println!("{}", output);
}
@@ -193,6 +186,8 @@ async fn ensure_daemon_running(project_path: &PathBuf) -> anyhow::Result<()> {
async fn execute_via_daemon(
client: &mut ipc::client::DaemonClient,
command: &Commands,
json_flag: bool,
pretty_flag: bool,
) -> anyhow::Result<String> {
let result = match command {
Commands::Import(args) => {
@@ -339,8 +334,23 @@ async fn execute_via_daemon(
_ => anyhow::bail!("Command not supported via daemon"),
};
// Format the JSON output nicely
serde_json::to_string_pretty(&result).map_err(Into::into)
// Determine output format based on flags and TTY detection
let format = if pretty_flag {
OutputFormat::Json
} else if json_flag {
OutputFormat::JsonCompact
} else {
auto_detect_format(atty::is(atty::Stream::Stdout))
};
// Detect if result is already an array before wrapping
let values = match result {
serde_json::Value::Array(arr) => arr,
single => vec![single],
};
let formatter = DefaultFormatter;
formatter.format(&values, format).map_err(Into::into)
}
/// Handle daemon management commands.
+52
View File
@@ -0,0 +1,52 @@
//! Integration tests for output format
//! Tests require real Ghidra installation
use assert_cmd::Command;
/// Helper to verify Ghidra is installed before running tests
fn require_ghidra() {
let output = Command::cargo_bin("ghidra")
.unwrap()
.arg("doctor")
.output()
.expect("Failed to run ghidra doctor");
if !output.status.success() {
panic!("Ghidra is not installed. Tests require Ghidra installation per AGENTS.md");
}
}
#[test]
fn test_format_detection_tty() {
require_ghidra();
// Test that --help shows both --json and --pretty flags
let mut cmd = Command::cargo_bin("ghidra").unwrap();
cmd.arg("--help");
cmd.assert().success();
let output = cmd.output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("--json"), "Help should show --json flag");
assert!(stdout.contains("--pretty"), "Help should show --pretty flag");
}
#[test]
fn test_json_flag() {
require_ghidra();
// Test --json flag is recognized
let mut cmd = Command::cargo_bin("ghidra").unwrap();
cmd.arg("--json").arg("--help");
cmd.assert().success();
}
#[test]
fn test_pretty_flag() {
require_ghidra();
// Test --pretty flag is recognized
let mut cmd = Command::cargo_bin("ghidra").unwrap();
cmd.arg("--pretty").arg("--help");
cmd.assert().success();
}