Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

### Changed

- Parallel fan-out outputs fold identical success spam: consecutive identical
lines collapse to `…[N identical lines folded]` and identical per-host blocks
share one `h1, h2:-> …` body with `…[output identical on N hosts folded]`;
`reboot` prints `rebooted & reconnected on h1, h2` and identical `FanOut`
failures list as `a, b: boom`. Only clean-success folds: a failed host's
lines stay verbatim even without signal keywords, `Diagnostic::degradation`
never folds, and clean empty output shares one banner. Verdicts, per-host
banners and error/errors/warning/warnings/`warn`/trace/traceback/stacktrace/
`keyerror`/assertionerror/`valueerror`/`typeerror`/`runtimeerror`/panic/
panicked/panics/fatal/critical/exception/timeout/cancel lines never fold and
stay at the head so `max_output_bytes` truncation preserves them. Signal
words match on word boundaries plus CamelCase transitions, so `IndexError`
and kin block as a class while `liberror`, `strace`, `timeouts`,
`warningsummary` and `perl-Error` still fold; CSI and OSC escapes are
stripped before the scan, so highlighted warnings, colored remote errors and
hyperlink-wrapped signals still block folding. Runs of blank lines fold,
single separators survive.
Identical-block sharing and identical-error
grouping both keep first-seen order, so hosts can regroup as `h1, h3` before
`h2` and the detail can read `a, c: boom; b: other` while headers keep
fan-out order. Output text only; MCP schemas unchanged.
- Commands that address no template — `load_template`, `unload`, `list_templates`,
`list_refhosts`, `updates`, `config`, `whoami`, `set_log_level`, and the REPL-only
`quit` (`exit`/`EOF`) and `switch` — no longer accept `-T/--template` or
Expand Down
86 changes: 85 additions & 1 deletion crates/mtui-core/src/commands/perform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,32 @@ use crate::session::Session;
/// Renders update-check [`Diagnostic`] sections: "Additional rpm output" with
/// the word `warning` recolored yellow, "not supported by its vendor" plain.
fn render_diagnostics(session: &mut Session, diagnostics: &[Diagnostic]) {
// Degradations never fold; only clean sections share a folded run.
let mut clean: Vec<String> = Vec::new();
let flush = |session: &mut Session, clean: &mut Vec<String>| {
for line in crate::fold::fold_output(clean) {
session.display.println(&line);
}
clean.clear();
};
for diag in diagnostics {
if diag.degradation {
flush(session, &mut clean);
for line in diag.text.split('\n') {
session.display.println(line);
}
continue;
}
let line = if diag.highlight_warning {
// `yellow` is a no-op under `ColorMode::Never`.
let yellow_warning = session.display.yellow("warning");
diag.text.replace("warning", &yellow_warning)
} else {
diag.text.clone()
};
session.display.println(&line);
clean.extend(line.split('\n').map(str::to_owned));
}
flush(session, &mut clean);
}

/// One of the report's `perform_*` workflow flows plus its parsed parameters.
Expand Down Expand Up @@ -285,6 +301,74 @@ mod tests {
assert!(buf.contents().is_empty());
}

#[test]
fn repetitive_diagnostics_fold_but_warnings_survive() {
let (mut session, buf) = session_with_color(ColorMode::Never);
let spam = vec![Diagnostic::plain("ok"); 5];
render_diagnostics(&mut session, &spam);
let out = buf.contents();
assert!(out.contains("…[4 identical lines folded]"), "{out:?}");
assert_eq!(out.lines().count(), 2, "{out:?}");

let (mut session, buf) = session_with_color(ColorMode::Never);
let warns = vec![Diagnostic::highlighted("warning: x"); 5];
render_diagnostics(&mut session, &warns);
let out = buf.contents();
assert!(!out.contains("identical"), "{out:?}");
assert_eq!(out.lines().count(), 5, "{out:?}");
}

#[test]
fn highlighted_warnings_survive_folding_under_color() {
// Under `Never` `yellow` is a no-op, so the test above cannot catch
// color-before-scan folding; here the escapes are real and the SGR
// `m` must not defeat the fold-safety scan.
let (mut session, buf) = session_with_color(ColorMode::Always);
let warns = vec![Diagnostic::highlighted("warning: x"); 5];
render_diagnostics(&mut session, &warns);
let out = buf.contents();
assert!(out.contains("\u{1b}["), "color must be on: {out:?}");
assert!(!out.contains("identical"), "{out:?}");
assert_eq!(out.lines().count(), 5, "{out:?}");
}

#[test]
fn degradation_repeats_never_fold() {
let (mut session, buf) = session_with_color(ColorMode::Never);
let degs = vec![Diagnostic::degradation("boom"); 5];
render_diagnostics(&mut session, &degs);
let out = buf.contents();
assert_eq!(
out.matches("boom").count(),
5,
"all repeats survive: {out:?}"
);
assert!(!out.contains("identical"), "{out:?}");
assert_eq!(out.lines().count(), 5, "{out:?}");
}

#[test]
fn clean_spam_folds_around_degradation() {
let (mut session, buf) = session_with_color(ColorMode::Never);
let diags = vec![
Diagnostic::plain("ok"),
Diagnostic::plain("ok"),
Diagnostic::plain("ok"),
Diagnostic::degradation("boom"),
Diagnostic::plain("ok"),
Diagnostic::plain("ok"),
Diagnostic::plain("ok"),
];
render_diagnostics(&mut session, &diags);
let out = buf.contents();
assert_eq!(
out.matches("…[2 identical lines folded]").count(),
2,
"{out:?}"
);
assert!(out.contains("boom"), "{out:?}");
}

// --- success confirmation ----------------------------------------------

use crate::commands::Update;
Expand Down
54 changes: 46 additions & 8 deletions crates/mtui-core/src/commands/reboot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,31 @@ impl Command for Reboot {
// rebooted); either must fail the command, so an MCP caller never sees a
// silent success on a host that did not reboot.
let mut failed: Vec<String> = Vec::new();
let mut ok: Vec<String> = Vec::new();
let mut failed_lines: Vec<String> = Vec::new();
for (host, outcome) in &outcomes {
match outcome {
Ok(()) => session
.display
.println(&format!("{host}: rebooted & reconnected")),
Ok(()) => ok.push(host.clone()),
Err(reason) => {
session
.display
.println(&format!("{host}: FAILED ({reason})"));
failed_lines.push(format!("{host}: FAILED ({reason})"));
failed.push(host.clone());
}
}
}
// Identical successes share one verdict; failures stay per-host.
// `outcomes` is a BTreeMap so `ok` is already sorted.
if ok.len() > 1 {
session
.display
.println(&format!("rebooted & reconnected on {}", ok.join(", ")));
} else if let Some(host) = ok.first() {
session
.display
.println(&format!("{host}: rebooted & reconnected"));
}
for line in &failed_lines {
session.display.println(line);
}

if failed.is_empty() {
Ok(())
Expand Down Expand Up @@ -131,8 +143,7 @@ mod tests {
// Reboot mutates in place and drops no host.
assert_eq!(session.targets().names(), vec!["h1", "h2"]);
let out = buf.contents();
assert!(out.contains("h1: rebooted & reconnected"), "{out}");
assert!(out.contains("h2: rebooted & reconnected"), "{out}");
assert!(out.contains("rebooted & reconnected on h1, h2"), "{out}");
assert!(!out.contains("FAILED"), "{out}");
}

Expand Down Expand Up @@ -180,6 +191,33 @@ mod tests {
assert!(matches!(err, CommandError::NoRefhostsDefined));
}

#[tokio::test]
async fn two_successes_and_a_failure_combine_successes() {
let (mut session, buf) = session_with_reboot_outcomes(
"SUSE:Maintenance:1:1",
&[("h1", true), ("h2", true), ("h3", false)],
);
let args = matches(&Reboot, &[]);
let err = Reboot.call(&mut session, &args).await.unwrap_err();
assert!(matches!(err, CommandError::Other(m) if m.contains("h3")));
let out = buf.contents();
assert!(out.contains("rebooted & reconnected on h1, h2"), "{out}");
assert!(out.contains("h3: FAILED"), "{out}");
}

#[tokio::test]
async fn all_fail_prints_only_failures() {
let (mut session, buf) =
session_with_reboot_outcomes("SUSE:Maintenance:1:1", &[("h1", false), ("h2", false)]);
let args = matches(&Reboot, &[]);
let err = Reboot.call(&mut session, &args).await.unwrap_err();
assert!(matches!(err, CommandError::Other(m) if m.contains("h1") && m.contains("h2")));
let out = buf.contents();
assert!(!out.contains("rebooted & reconnected"), "{out}");
assert!(out.contains("h1: FAILED"), "{out}");
assert!(out.contains("h2: FAILED"), "{out}");
}

#[tokio::test]
async fn unknown_named_host_is_not_connected() {
let (mut session, _buf) = session_with_hosts("SUSE:Maintenance:1:1", &["h1"], "ok");
Expand Down
Loading