Skip to content

Commit 2db074c

Browse files
committed
fix(agent): honor global JSON output format
- apply global --format json/json-compact to guard/validate/suggest - preserve legacy subcommand --json compact one-line output - cover offline and server-mode fallback format contracts
1 parent 018d186 commit 2db074c

2 files changed

Lines changed: 268 additions & 16 deletions

File tree

crates/terraphim_agent/src/main.rs

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,24 @@ fn resolve_output_config(robot: bool, format: OutputFormat) -> CommandOutputConf
587587
CommandOutputConfig { mode, robot }
588588
}
589589

590+
fn effective_json_output_mode(
591+
subcommand_json: bool,
592+
output: CommandOutputConfig,
593+
) -> Option<CommandOutputMode> {
594+
if !subcommand_json && !output.is_machine_readable() {
595+
return None;
596+
}
597+
598+
Some(
599+
if subcommand_json && matches!(output.mode, CommandOutputMode::Human) {
600+
// Historical subcommand `--json` output used compact, single-line JSON.
601+
CommandOutputMode::JsonCompact
602+
} else {
603+
output.mode
604+
},
605+
)
606+
}
607+
590608
#[cfg(feature = "repl-sessions")]
591609
mod session_output {
592610
use serde::Serialize;
@@ -1988,8 +2006,8 @@ async fn run_offline_command(
19882006
};
19892007
let result = guard.check(&input_command);
19902008

1991-
if *json {
1992-
println!("{}", serde_json::to_string(&result)?);
2009+
if let Some(json_mode) = effective_json_output_mode(*json, output) {
2010+
print_json_output(&result, json_mode)?;
19932011
} else if result.decision == guard_patterns::GuardDecision::Block {
19942012
if let Some(reason) = &result.reason {
19952013
eprintln!("BLOCKED: {}", reason);
@@ -2570,12 +2588,13 @@ async fn run_offline_command(
25702588
};
25712589

25722590
let role_name = service.resolve_role(role.as_deref()).await?;
2591+
let json_mode = effective_json_output_mode(json, output);
25732592

25742593
if connectivity {
25752594
let result = service.check_connectivity(&role_name, &input_text).await?;
25762595

2577-
if json {
2578-
println!("{}", serde_json::to_string(&result)?);
2596+
if let Some(json_mode) = json_mode {
2597+
print_json_output(&result, json_mode)?;
25792598
} else {
25802599
println!("Connectivity Check for role '{}':", role_name);
25812600
println!(" Connected: {}", result.connected);
@@ -2588,8 +2607,8 @@ async fn run_offline_command(
25882607
.validate_checklist(&role_name, &checklist_name, &input_text)
25892608
.await?;
25902609

2591-
if json {
2592-
println!("{}", serde_json::to_string(&result)?);
2610+
if let Some(json_mode) = json_mode {
2611+
print_json_output(&result, json_mode)?;
25932612
} else {
25942613
println!(
25952614
"Checklist '{}' Validation for role '{}':",
@@ -2614,13 +2633,13 @@ async fn run_offline_command(
26142633
// Default validation: find matches
26152634
let matches = service.find_matches(&role_name, &input_text).await?;
26162635

2617-
if json {
2636+
if let Some(json_mode) = json_mode {
26182637
let output = serde_json::json!({
26192638
"role": role_name.to_string(),
26202639
"matched_count": matches.len(),
26212640
"matches": matches.iter().map(|m| m.term.clone()).collect::<Vec<_>>()
26222641
});
2623-
println!("{}", serde_json::to_string(&output)?);
2642+
print_json_output(&output, json_mode)?;
26242643
} else {
26252644
println!("Validation for role '{}':", role_name);
26262645
println!(" Found {} matched term(s)", matches.len());
@@ -2651,13 +2670,14 @@ async fn run_offline_command(
26512670
};
26522671

26532672
let role_name = service.resolve_role(role.as_deref()).await?;
2673+
let json_mode = effective_json_output_mode(json, output);
26542674

26552675
let suggestions = service
26562676
.fuzzy_suggest(&role_name, &input_query, threshold, Some(limit))
26572677
.await?;
26582678

2659-
if json {
2660-
println!("{}", serde_json::to_string(&suggestions)?);
2679+
if let Some(json_mode) = json_mode {
2680+
print_json_output(&suggestions, json_mode)?;
26612681
} else if suggestions.is_empty() {
26622682
println!(
26632683
"No suggestions found for '{}' with threshold {}",
@@ -4669,22 +4689,22 @@ async fn run_server_command(
46694689
}
46704690
}
46714691
Command::Validate { json, .. } => {
4672-
if json {
4692+
if let Some(json_mode) = effective_json_output_mode(json, output) {
46734693
let err = serde_json::json!({
46744694
"error": "Validate command is only available in offline mode"
46754695
});
4676-
println!("{}", serde_json::to_string(&err)?);
4696+
print_json_output(&err, json_mode)?;
46774697
} else {
46784698
eprintln!("Validate command is only available in offline mode");
46794699
}
46804700
std::process::exit(1);
46814701
}
46824702
Command::Suggest { json, .. } => {
4683-
if json {
4703+
if let Some(json_mode) = effective_json_output_mode(json, output) {
46844704
let err = serde_json::json!({
46854705
"error": "Suggest command is only available in offline mode"
46864706
});
4687-
println!("{}", serde_json::to_string(&err)?);
4707+
print_json_output(&err, json_mode)?;
46884708
} else {
46894709
eprintln!("Suggest command is only available in offline mode");
46904710
}
@@ -4748,8 +4768,8 @@ async fn run_server_command(
47484768
};
47494769
let result = guard.check(&input_command);
47504770

4751-
if json {
4752-
println!("{}", serde_json::to_string(&result)?);
4771+
if let Some(json_mode) = effective_json_output_mode(json, output) {
4772+
print_json_output(&result, json_mode)?;
47534773
} else if result.decision == guard_patterns::GuardDecision::Block {
47544774
if let Some(reason) = &result.reason {
47554775
eprintln!("BLOCKED: {}", reason);
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
use anyhow::{Context, Result};
2+
use serde_json::Value;
3+
use std::process::Command;
4+
5+
mod support;
6+
use support::cli_test_env::apply_hermetic_env;
7+
8+
fn run_agent(args: &[&str]) -> Result<(String, String, i32)> {
9+
let mut cmd = Command::new(env!("CARGO_BIN_EXE_terraphim-agent"));
10+
cmd.args(args);
11+
apply_hermetic_env(&mut cmd)?;
12+
13+
let output = cmd.output().context("run terraphim-agent")?;
14+
15+
Ok((
16+
String::from_utf8_lossy(&output.stdout).to_string(),
17+
String::from_utf8_lossy(&output.stderr).to_string(),
18+
output.status.code().unwrap_or(-1),
19+
))
20+
}
21+
22+
#[test]
23+
fn global_json_compact_guard_emits_single_line_json() -> Result<()> {
24+
let (stdout, stderr, code) =
25+
run_agent(&["--format", "json-compact", "guard", "git reset --hard HEAD"])?;
26+
27+
assert_eq!(
28+
code, 0,
29+
"machine-readable guard should preserve --json compatibility; stderr={stderr}"
30+
);
31+
assert!(
32+
!stderr.contains("BLOCKED:"),
33+
"human BLOCKED text must not be emitted for global JSON format: {stderr}"
34+
);
35+
36+
let trimmed = stdout.trim();
37+
assert!(!trimmed.is_empty(), "stdout should contain JSON");
38+
assert_eq!(
39+
trimmed.lines().count(),
40+
1,
41+
"json-compact should be single-line"
42+
);
43+
let json: Value = serde_json::from_str(trimmed)?;
44+
assert_eq!(json["decision"], "block");
45+
assert_eq!(json["command"], "git reset --hard HEAD");
46+
Ok(())
47+
}
48+
49+
#[test]
50+
fn global_json_validate_emits_parseable_json() -> Result<()> {
51+
let (stdout, stderr, code) = run_agent(&["--format", "json", "validate", "terraphim"])?;
52+
53+
assert_eq!(code, 0, "validate should succeed; stderr={stderr}");
54+
let json: Value = serde_json::from_str(stdout.trim())?;
55+
assert!(
56+
json.get("matched_count").is_some() || json.get("error").is_some(),
57+
"unexpected validate JSON: {json}"
58+
);
59+
Ok(())
60+
}
61+
62+
#[test]
63+
fn global_json_guard_emits_pretty_json() -> Result<()> {
64+
let (stdout, stderr, code) =
65+
run_agent(&["--format", "json", "guard", "git reset --hard HEAD"])?;
66+
67+
assert_eq!(
68+
code, 0,
69+
"global --format json guard should succeed; stderr={stderr}"
70+
);
71+
assert!(
72+
!stderr.contains("BLOCKED:"),
73+
"human BLOCKED text must not be emitted for global JSON format: {stderr}"
74+
);
75+
let json = assert_pretty_json(&stdout)?;
76+
assert_eq!(json["decision"], "block");
77+
assert_eq!(json["command"], "git reset --hard HEAD");
78+
Ok(())
79+
}
80+
81+
#[test]
82+
fn global_json_compact_suggest_emits_parseable_json() -> Result<()> {
83+
let (stdout, stderr, code) = run_agent(&[
84+
"--format",
85+
"json-compact",
86+
"suggest",
87+
"terraphim",
88+
"--limit",
89+
"3",
90+
])?;
91+
92+
assert_eq!(code, 0, "suggest should succeed; stderr={stderr}");
93+
assert_single_line_json(&stdout)?;
94+
Ok(())
95+
}
96+
97+
#[test]
98+
fn legacy_guard_json_stays_single_line_compact() -> Result<()> {
99+
let (stdout, stderr, code) = run_agent(&["guard", "--json", "git reset --hard HEAD"])?;
100+
101+
assert_eq!(
102+
code, 0,
103+
"legacy guard --json compatibility; stderr={stderr}"
104+
);
105+
assert!(
106+
!stderr.contains("BLOCKED:"),
107+
"legacy JSON guard should not emit human BLOCKED text: {stderr}"
108+
);
109+
let json = assert_single_line_json(&stdout)?;
110+
assert_eq!(json["decision"], "block");
111+
Ok(())
112+
}
113+
114+
#[test]
115+
fn legacy_validate_json_stays_single_line_compact() -> Result<()> {
116+
let (stdout, stderr, code) = run_agent(&["validate", "--json", "terraphim"])?;
117+
118+
assert_eq!(
119+
code, 0,
120+
"legacy validate --json compatibility; stderr={stderr}"
121+
);
122+
let json = assert_single_line_json(&stdout)?;
123+
assert!(
124+
json.get("matched_count").is_some() || json.get("error").is_some(),
125+
"unexpected validate JSON: {json}"
126+
);
127+
Ok(())
128+
}
129+
130+
#[test]
131+
fn legacy_suggest_json_stays_single_line_compact() -> Result<()> {
132+
let (stdout, stderr, code) = run_agent(&["suggest", "--json", "terraphim", "--limit", "3"])?;
133+
134+
assert_eq!(
135+
code, 0,
136+
"legacy suggest --json compatibility; stderr={stderr}"
137+
);
138+
assert_single_line_json(&stdout)?;
139+
Ok(())
140+
}
141+
142+
#[test]
143+
fn server_mode_global_json_compact_guard_emits_single_line_json() -> Result<()> {
144+
let (stdout, stderr, code) = run_agent(&[
145+
"--server",
146+
"--format",
147+
"json-compact",
148+
"guard",
149+
"git reset --hard HEAD",
150+
])?;
151+
152+
assert_eq!(
153+
code, 0,
154+
"machine-readable server-mode guard should preserve --json compatibility; stderr={stderr}"
155+
);
156+
assert!(
157+
!stderr.contains("BLOCKED:"),
158+
"human BLOCKED text must not be emitted for server-mode global JSON format: {stderr}"
159+
);
160+
let json = assert_single_line_json(&stdout)?;
161+
assert_eq!(json["decision"], "block");
162+
Ok(())
163+
}
164+
165+
#[test]
166+
fn server_mode_global_json_validate_error_is_parseable_json() -> Result<()> {
167+
let (stdout, stderr, code) = run_agent(&[
168+
"--server",
169+
"--format",
170+
"json-compact",
171+
"validate",
172+
"terraphim",
173+
])?;
174+
175+
assert_eq!(code, 1, "server-mode validate should remain unavailable");
176+
assert!(
177+
!stderr.contains("Validate command is only available in offline mode"),
178+
"machine-readable unavailable error must be stdout JSON, not human stderr: {stderr}"
179+
);
180+
let json = assert_single_line_json(&stdout)?;
181+
assert_eq!(
182+
json["error"],
183+
"Validate command is only available in offline mode"
184+
);
185+
Ok(())
186+
}
187+
188+
#[test]
189+
fn server_mode_global_json_compact_suggest_error_is_parseable_json() -> Result<()> {
190+
let (stdout, stderr, code) = run_agent(&[
191+
"--server",
192+
"--format",
193+
"json-compact",
194+
"suggest",
195+
"terraphim",
196+
"--limit",
197+
"3",
198+
])?;
199+
200+
assert_eq!(code, 1, "server-mode suggest should remain unavailable");
201+
assert!(
202+
!stderr.contains("Suggest command is only available in offline mode"),
203+
"machine-readable unavailable error must be stdout JSON, not human stderr: {stderr}"
204+
);
205+
let json = assert_single_line_json(&stdout)?;
206+
assert_eq!(
207+
json["error"],
208+
"Suggest command is only available in offline mode"
209+
);
210+
Ok(())
211+
}
212+
213+
fn assert_single_line_json(stdout: &str) -> Result<Value> {
214+
let trimmed = stdout.trim();
215+
assert!(!trimmed.is_empty(), "stdout should contain JSON");
216+
assert_eq!(
217+
trimmed.lines().count(),
218+
1,
219+
"json-compact should be single-line"
220+
);
221+
Ok(serde_json::from_str(trimmed)?)
222+
}
223+
224+
fn assert_pretty_json(stdout: &str) -> Result<Value> {
225+
let trimmed = stdout.trim();
226+
assert!(!trimmed.is_empty(), "stdout should contain JSON");
227+
assert!(
228+
trimmed.lines().count() > 1,
229+
"--format json should be pretty multi-line JSON; stdout={trimmed}"
230+
);
231+
Ok(serde_json::from_str(trimmed)?)
232+
}

0 commit comments

Comments
 (0)