Skip to content

Commit 9609d93

Browse files
hyperpolymathclaude
andcommitted
feat(tui,gui): add --headless mode for CI-safe operation
tui --headless and gui --headless bypass crossterm/eframe respectively, printing section/panel summaries to stdout without requiring a TTY or display server. Both subcommands advance from Grade E (untestable) to Grade D (Alpha) in the CRG scale. Adds two Grade D readiness tests: readiness_d_tui_headless_runs and readiness_d_gui_headless_runs, which exercise the full parse-and-render path using a minimal AssaultReport fixture. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fd90d2b commit 9609d93

4 files changed

Lines changed: 216 additions & 4 deletions

File tree

src/main.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -458,13 +458,21 @@ enum Commands {
458458
/// Assault report JSON file
459459
#[arg(value_name = "REPORT")]
460460
report: PathBuf,
461+
462+
/// Headless mode: print sections as plain text without requiring a TTY (CI-safe)
463+
#[arg(long, default_value_t = false)]
464+
headless: bool,
461465
},
462466

463467
/// GUI review of a saved report
464468
Gui {
465469
/// Assault report JSON file
466470
#[arg(value_name = "REPORT")]
467471
report: PathBuf,
472+
473+
/// Headless mode: print panel summaries without requiring a display server (CI-safe)
474+
#[arg(long, default_value_t = false)]
475+
headless: bool,
468476
},
469477

470478
/// Compare two assault reports (defaults to latest VerisimDB runs)
@@ -1662,16 +1670,24 @@ fn run_main() -> Result<()> {
16621670
}
16631671
}
16641672

1665-
Commands::Tui { report } => {
1673+
Commands::Tui { report, headless } => {
16661674
let content = read_report_bounded(&report)?;
16671675
let assault_report: AssaultReport = serde_json::from_str(&content)?;
1668-
ReportTui::run(&assault_report)?;
1676+
if headless {
1677+
ReportTui::run_headless(&assault_report)?;
1678+
} else {
1679+
ReportTui::run(&assault_report)?;
1680+
}
16691681
}
16701682

1671-
Commands::Gui { report } => {
1683+
Commands::Gui { report, headless } => {
16721684
let content = read_report_bounded(&report)?;
16731685
let assault_report: AssaultReport = serde_json::from_str(&content)?;
1674-
report::ReportGui::run(assault_report)?;
1686+
if headless {
1687+
report::ReportGui::run_headless(assault_report)?;
1688+
} else {
1689+
report::ReportGui::run(assault_report)?;
1690+
}
16751691
}
16761692

16771693
Commands::Diff {

src/report/gui.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,96 @@ impl ReportGui {
6565
Ok(())
6666
}
6767

68+
/// Run without a display server: print a structured text summary of all panels.
69+
///
70+
/// Safe to call in CI or headless environments with no Wayland/X11 display.
71+
/// Promotes the gui subcommand from Grade E to Grade D.
72+
pub fn run_headless(report: AssaultReport) -> Result<()> {
73+
let assail = &report.assail_report;
74+
let formatter = ReportFormatter::new();
75+
76+
println!("PANIC-ATTACK GUI REPORT (headless)");
77+
println!();
78+
79+
println!("=== Summary ===");
80+
println!("Language: {:?}", assail.language);
81+
println!("Score: {:.1}/100", report.overall_assessment.robustness_score);
82+
println!("Crashes: {}", report.total_crashes);
83+
println!("Signatures: {}", report.total_signatures);
84+
println!("Weak points: {}", assail.weak_points.len());
85+
println!("Frameworks: {:?}", assail.frameworks);
86+
println!();
87+
88+
println!("=== Assail ===");
89+
println!("Program: {}", assail.program_path.display());
90+
println!(
91+
"Stats: lines={} unsafe={} panics={} unwraps={}",
92+
assail.statistics.total_lines,
93+
assail.statistics.unsafe_blocks,
94+
assail.statistics.panic_sites,
95+
assail.statistics.unwrap_calls
96+
);
97+
for wp in &assail.weak_points {
98+
let loc = wp
99+
.location
100+
.as_deref()
101+
.or(wp.file.as_deref())
102+
.unwrap_or("<unknown>");
103+
println!(
104+
" [{:?}] {:?} @ {} — {}",
105+
wp.severity, wp.category, loc, wp.description
106+
);
107+
}
108+
println!();
109+
110+
println!("=== File Risk ===");
111+
for detail in formatter.file_risk_details(assail) {
112+
println!(" {}", detail);
113+
}
114+
println!();
115+
116+
println!("=== Matrix ===");
117+
println!(
118+
"Dependency edges: {} Taint rows: {}",
119+
assail.dependency_graph.edges.len(),
120+
assail.taint_matrix.rows.len()
121+
);
122+
for detail in formatter.taint_matrix_details(assail) {
123+
println!(" {}", detail);
124+
}
125+
println!();
126+
127+
println!("=== Attacks ===");
128+
for result in &report.attack_results {
129+
let status = if result.skipped {
130+
"skipped"
131+
} else if result.success {
132+
"passed"
133+
} else {
134+
"failed"
135+
};
136+
println!(
137+
" {:?}: {} crashes={} duration={:.2}s",
138+
result.axis,
139+
status,
140+
result.crashes.len(),
141+
result.duration.as_secs_f64()
142+
);
143+
}
144+
println!();
145+
146+
println!("=== Assessment ===");
147+
for issue in &report.overall_assessment.critical_issues {
148+
println!(" CRITICAL: {}", issue);
149+
}
150+
for rec in &report.overall_assessment.recommendations {
151+
println!(" REC: {}", rec);
152+
}
153+
println!();
154+
155+
Ok(())
156+
}
157+
68158
/// Attempt to load a JSON file as either an `AssaultReport`, `SystemImage`,
69159
/// or `TemporalDiff`. Detection is format-based: we try each in turn and
70160
/// keep the first successful parse.

src/report/tui.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,29 @@ use std::time::Duration;
1818
pub struct ReportTui;
1919

2020
impl ReportTui {
21+
/// Run without a TTY: build all sections and print them as plain text.
22+
///
23+
/// Safe to call in CI, scripts, or any context without a real terminal.
24+
/// Promotes the tui subcommand from Grade E to Grade D.
25+
pub fn run_headless(report: &AssaultReport) -> Result<()> {
26+
let formatter = ReportFormatter::new();
27+
let sections = Self::build_sections(report, &formatter, true);
28+
29+
println!("PANIC-ATTACK REPORT REVIEW (headless)");
30+
println!();
31+
32+
for section in &sections {
33+
println!("=== {} ===", section.title);
34+
println!("{}", section.summary);
35+
for detail in &section.details {
36+
println!(" {}", detail);
37+
}
38+
println!();
39+
}
40+
41+
Ok(())
42+
}
43+
2144
pub fn run(report: &AssaultReport) -> Result<()> {
2245
terminal::enable_raw_mode()?;
2346
let result = Self::run_inner(report);

tests/readiness.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,43 @@ fn run(args: &[&str]) -> (bool, String, String) {
5252
)
5353
}
5454

55+
/// Minimal `AssaultReport` JSON for headless subcommand tests.
56+
///
57+
/// Matches the full serde schema (all required fields present) so
58+
/// `serde_json::from_str::<AssaultReport>` succeeds without panic.
59+
fn minimal_assault_report_json() -> String {
60+
serde_json::json!({
61+
"schema_version": "2.5",
62+
"assail_report": {
63+
"schema_version": "2.5",
64+
"program_path": "/tmp/headless-test",
65+
"language": "rust",
66+
"frameworks": [],
67+
"weak_points": [],
68+
"statistics": {
69+
"total_lines": 10,
70+
"unsafe_blocks": 0,
71+
"panic_sites": 0,
72+
"unwrap_calls": 0,
73+
"allocation_sites": 0,
74+
"io_operations": 0,
75+
"threading_constructs": 0
76+
},
77+
"file_statistics": [],
78+
"recommended_attacks": []
79+
},
80+
"attack_results": [],
81+
"total_crashes": 0,
82+
"total_signatures": 0,
83+
"overall_assessment": {
84+
"robustness_score": 100.0,
85+
"critical_issues": [],
86+
"recommendations": []
87+
}
88+
})
89+
.to_string()
90+
}
91+
5592
// ============================================================
5693
// Grade D (Alpha): each component runs without crashing
5794
// ============================================================
@@ -82,6 +119,52 @@ fn readiness_d_help_runs() {
82119
assert!(ok, "help should succeed: {}", stderr);
83120
}
84121

122+
#[test]
123+
fn readiness_d_tui_headless_runs() {
124+
let dir = TempDir::new().unwrap();
125+
let report_path = dir.path().join("assault.json");
126+
fs::write(&report_path, minimal_assault_report_json()).unwrap();
127+
128+
let (ok, stdout, stderr) = run(&["tui", "--headless", report_path.to_str().unwrap()]);
129+
assert!(ok, "tui --headless should succeed without a TTY: {}", stderr);
130+
assert!(
131+
stdout.contains("PANIC-ATTACK REPORT REVIEW"),
132+
"tui --headless should print report header, got: {}",
133+
stdout
134+
);
135+
assert!(
136+
stdout.contains("Assail Summary"),
137+
"tui --headless should include Assail Summary section"
138+
);
139+
}
140+
141+
#[test]
142+
fn readiness_d_gui_headless_runs() {
143+
let dir = TempDir::new().unwrap();
144+
let report_path = dir.path().join("assault.json");
145+
fs::write(&report_path, minimal_assault_report_json()).unwrap();
146+
147+
let (ok, stdout, stderr) = run(&["gui", "--headless", report_path.to_str().unwrap()]);
148+
assert!(
149+
ok,
150+
"gui --headless should succeed without a display server: {}",
151+
stderr
152+
);
153+
assert!(
154+
stdout.contains("PANIC-ATTACK GUI REPORT"),
155+
"gui --headless should print GUI report header, got: {}",
156+
stdout
157+
);
158+
assert!(
159+
stdout.contains("Summary"),
160+
"gui --headless should include Summary panel"
161+
);
162+
assert!(
163+
stdout.contains("Assessment"),
164+
"gui --headless should include Assessment panel"
165+
);
166+
}
167+
85168
// ============================================================
86169
// Grade C (Beta): components produce correct output
87170
// ============================================================

0 commit comments

Comments
 (0)