Skip to main content

ssh_cli/output/
batch.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Multi-host / multi-result batch emitters (G-COMP-06d).
3//!
4//! Keeps fan-out JSON/text formatting separate from single-record CRUD emitters.
5#![forbid(unsafe_code)]
6
7use super::{is_quiet, report_json_serialize_error};
8use crate::domain::BatchRunId;
9use crate::json_wire::{
10    self, ExecBatchJson, ExecHostJson, HealthBatchJson, HealthHostJson, ScpBatchJson, ScpHostJson,
11    ScpTransferJson, SftpBatchJson, SftpFsOpJson, SftpListEntryJson, SftpListJson, SftpTransferJson,
12    TunnelListeningJson,
13};
14use crate::ssh::sftp_types::{SftpListEntry, SftpStat};
15use crate::sftp::batch::HostSftpResult;
16use crate::vps::{HostExecResult, HostHealthResult};
17use std::io::{self, Write};
18
19/// Prints multi-host health-check results (text or single-root JSON batch).
20///
21/// # Errors
22/// Serialization or stdout I/O.
23pub fn print_health_batch(
24    results: &[HostHealthResult],
25    max_concurrency: usize,
26    json: bool,
27) -> io::Result<()> {
28    if json {
29        // One v7 id per fan-out command (before/with emit; not per host).
30        let batch_run_id = BatchRunId::new().to_string_canonical();
31        let v = HealthBatchJson {
32            event: "health-check-batch".into(),
33            batch_run_id,
34            max_concurrency: u32::try_from(max_concurrency).unwrap_or(u32::MAX),
35            results: results
36                .iter()
37                .map(|h| HealthHostJson {
38                    name: h.name.clone(),
39                    status: if h.ok { "ok".into() } else { "error".into() },
40                    latency_ms: h.latency_ms,
41                    error: h.error.clone(),
42                })
43                .collect(),
44        };
45        return match json_wire::print_json_line(&v) {
46            Ok(()) => Ok(()),
47            Err(e) => {
48                report_json_serialize_error(&e);
49                Err(e)
50            }
51        };
52    }
53    if is_quiet() {
54        return Ok(());
55    }
56    let stdout = io::stdout();
57    let mut out = io::BufWriter::new(stdout.lock());
58    writeln!(
59        out,
60        "health-check --all (max_concurrency={max_concurrency}, hosts={})",
61        results.len()
62    )?;
63    for h in results {
64        match (h.ok, h.latency_ms) {
65            (true, Some(ms)) => writeln!(out, "  ok  {}  {ms}ms", h.name)?,
66            (true, None) => writeln!(out, "  ok  {}", h.name)?,
67            (false, _) => {
68                let err = h.error.as_deref().unwrap_or("error");
69                writeln!(out, "  ERR {}  {err}", h.name)?;
70            }
71        }
72    }
73    out.flush()
74}
75
76/// Prints multi-host exec results (text or single-root JSON batch).
77///
78/// # Errors
79/// Serialization or stdout I/O.
80pub fn print_exec_batch(
81    results: &[HostExecResult],
82    max_concurrency: usize,
83    json: bool,
84) -> io::Result<()> {
85    if json {
86        let batch_run_id = BatchRunId::new().to_string_canonical();
87        let v = ExecBatchJson {
88            event: "exec-batch".into(),
89            batch_run_id,
90            max_concurrency: u32::try_from(max_concurrency).unwrap_or(u32::MAX),
91            results: results
92                .iter()
93                .map(|h| ExecHostJson {
94                    name: h.name.clone(),
95                    ok: h.ok,
96                    exit_code: h.exit_code,
97                    stdout: h.stdout.clone(),
98                    stderr: h.stderr.clone(),
99                    duration_ms: h.duration_ms,
100                    error: h.error.clone(),
101                })
102                .collect(),
103        };
104        return match json_wire::print_json_line(&v) {
105            Ok(()) => Ok(()),
106            Err(e) => {
107                report_json_serialize_error(&e);
108                Err(e)
109            }
110        };
111    }
112    if is_quiet() {
113        return Ok(());
114    }
115    let stdout = io::stdout();
116    let mut out = io::BufWriter::new(stdout.lock());
117    writeln!(
118        out,
119        "exec --all (max_concurrency={max_concurrency}, hosts={})",
120        results.len()
121    )?;
122    for h in results {
123        let status = if h.ok { "ok" } else { "ERR" };
124        writeln!(
125            out,
126            "  {status}  {}  exit={:?}  {}ms",
127            h.name, h.exit_code, h.duration_ms
128        )?;
129        if !h.stdout.is_empty() {
130            for line in h.stdout.lines() {
131                writeln!(out, "    | {line}")?;
132            }
133        }
134        if !h.stderr.is_empty() {
135            for line in h.stderr.lines() {
136                writeln!(out, "    ! {line}")?;
137            }
138        }
139    }
140    out.flush()
141}
142
143/// Prints multi-host SCP batch results.
144///
145/// # Errors
146/// Serialization or stdout I/O.
147pub fn print_scp_batch(
148    direction: &str,
149    results: &[crate::scp::HostScpResult],
150    max_concurrency: usize,
151    json: bool,
152) -> io::Result<()> {
153    if json {
154        let batch_run_id = BatchRunId::new().to_string_canonical();
155        let v = ScpBatchJson {
156            event: "scp-batch".into(),
157            batch_run_id,
158            direction: direction.into(),
159            max_concurrency: u32::try_from(max_concurrency).unwrap_or(u32::MAX),
160            results: results
161                .iter()
162                .map(|h| ScpHostJson {
163                    name: h.name.clone(),
164                    ok: h.ok,
165                    bytes: h.bytes,
166                    duration_ms: h.duration_ms,
167                    local: h.local.clone(),
168                    error: h.error.clone(),
169                })
170                .collect(),
171        };
172        return match json_wire::print_json_line(&v) {
173            Ok(()) => Ok(()),
174            Err(e) => {
175                report_json_serialize_error(&e);
176                Err(e)
177            }
178        };
179    }
180    if is_quiet() {
181        return Ok(());
182    }
183    let stdout = io::stdout();
184    let mut out = io::BufWriter::new(stdout.lock());
185    writeln!(
186        out,
187        "scp {direction} --all (max_concurrency={max_concurrency}, hosts={})",
188        results.len()
189    )?;
190    for h in results {
191        if h.ok {
192            writeln!(
193                out,
194                "  ok  {}  bytes={:?}  {:?}ms",
195                h.name, h.bytes, h.duration_ms
196            )?;
197        } else {
198            let err = h.error.as_deref().unwrap_or("error");
199            writeln!(out, "  ERR {}  {err}", h.name)?;
200        }
201    }
202    out.flush()
203}
204
205/// Prints an SCP transfer result as JSON (GAP-SSH-IO-007 / SCP-021 / IO-009).
206///
207/// # Errors
208/// Serialization or stdout I/O (including BrokenPipe).
209pub fn print_transfer_json(
210    direction: &str,
211    vps: &str,
212    local: &str,
213    remote: &str,
214    bytes: u64,
215    duration_ms: u64,
216) -> io::Result<()> {
217    // GAP-SSH-IO-009: event discriminator (parity with tunnel_listening).
218    let v = ScpTransferJson {
219        ok: true,
220        event: "scp-transfer".into(),
221        direction: direction.to_string(),
222        vps: vps.to_string(),
223        local: local.to_string(),
224        remote: remote.to_string(),
225        bytes,
226        duration_ms,
227    };
228    match json_wire::print_json_line(&v) {
229        Ok(()) => Ok(()),
230        Err(e) => {
231            report_json_serialize_error(&e);
232            Err(e)
233        }
234    }
235}
236
237/// Prints an SFTP transfer result as JSON (G-SFTP-09).
238///
239/// # Errors
240/// Serialization or stdout I/O.
241pub fn print_sftp_transfer_json(
242    direction: &str,
243    vps: &str,
244    local: &str,
245    remote: &str,
246    bytes: u64,
247    duration_ms: u64,
248    recursive: bool,
249) -> io::Result<()> {
250    let v = SftpTransferJson {
251        ok: true,
252        event: "sftp-transfer".into(),
253        direction: direction.to_string(),
254        vps: vps.to_string(),
255        local: local.to_string(),
256        remote: remote.to_string(),
257        bytes,
258        duration_ms,
259        recursive,
260    };
261    match json_wire::print_json_line(&v) {
262        Ok(()) => Ok(()),
263        Err(e) => {
264            report_json_serialize_error(&e);
265            Err(e)
266        }
267    }
268}
269
270/// Prints `sftp ls` JSON.
271///
272/// # Errors
273/// Serialization or stdout I/O.
274pub fn print_sftp_list_json(vps: &str, path: &str, entries: &[SftpListEntry]) -> io::Result<()> {
275    let v = SftpListJson {
276        ok: true,
277        event: "sftp-list".into(),
278        vps: vps.to_string(),
279        path: path.to_string(),
280        entries: entries
281            .iter()
282            .map(|e| SftpListEntryJson {
283                name: e.name.clone(),
284                path: e.path.clone(),
285                kind: e.kind.clone(),
286                size: e.size,
287                mode: e.mode,
288            })
289            .collect(),
290    };
291    match json_wire::print_json_line(&v) {
292        Ok(()) => Ok(()),
293        Err(e) => {
294            report_json_serialize_error(&e);
295            Err(e)
296        }
297    }
298}
299
300/// Prints `sftp` fs-op JSON (mkdir/rmdir/rm/rename).
301///
302/// # Errors
303/// Serialization or stdout I/O.
304pub fn print_sftp_fs_op_json(
305    op: &str,
306    vps: &str,
307    path: &str,
308    to: Option<&str>,
309    duration_ms: u64,
310) -> io::Result<()> {
311    let v = SftpFsOpJson {
312        ok: true,
313        event: "sftp-fs-op".into(),
314        op: op.to_string(),
315        vps: vps.to_string(),
316        path: path.to_string(),
317        to: to.map(str::to_owned),
318        duration_ms,
319        kind: None,
320        size: None,
321        mode: None,
322        mtime: None,
323    };
324    match json_wire::print_json_line(&v) {
325        Ok(()) => Ok(()),
326        Err(e) => {
327            report_json_serialize_error(&e);
328            Err(e)
329        }
330    }
331}
332
333/// Prints `sftp stat` JSON.
334///
335/// # Errors
336/// Serialization or stdout I/O.
337pub fn print_sftp_stat_json(vps: &str, st: &SftpStat) -> io::Result<()> {
338    let v = SftpFsOpJson {
339        ok: true,
340        event: "sftp-fs-op".into(),
341        op: "stat".into(),
342        vps: vps.to_string(),
343        path: st.path.clone(),
344        to: None,
345        duration_ms: 0,
346        kind: Some(st.kind.clone()),
347        size: st.size,
348        mode: st.mode,
349        mtime: st.mtime,
350    };
351    match json_wire::print_json_line(&v) {
352        Ok(()) => Ok(()),
353        Err(e) => {
354            report_json_serialize_error(&e);
355            Err(e)
356        }
357    }
358}
359
360/// Prints multi-host SFTP batch results.
361///
362/// # Errors
363/// Serialization or stdout I/O.
364pub fn print_sftp_batch(
365    direction: &str,
366    results: &[HostSftpResult],
367    max_concurrency: usize,
368    json: bool,
369) -> io::Result<()> {
370    if json {
371        let batch_run_id = BatchRunId::new().to_string_canonical();
372        let v = SftpBatchJson {
373            event: "sftp-batch".into(),
374            batch_run_id,
375            direction: direction.to_string(),
376            max_concurrency: u32::try_from(max_concurrency).unwrap_or(u32::MAX),
377            results: results
378                .iter()
379                .map(|h| ScpHostJson {
380                    name: h.name.clone(),
381                    ok: h.ok,
382                    bytes: h.bytes,
383                    duration_ms: h.duration_ms,
384                    local: h.local.clone(),
385                    error: h.error.clone(),
386                })
387                .collect(),
388        };
389        return match json_wire::print_json_line(&v) {
390            Ok(()) => Ok(()),
391            Err(e) => {
392                report_json_serialize_error(&e);
393                Err(e)
394            }
395        };
396    }
397    if is_quiet() {
398        return Ok(());
399    }
400    let stdout = io::stdout();
401    let mut out = io::BufWriter::new(stdout.lock());
402    writeln!(
403        out,
404        "sftp {direction} --all (max_concurrency={max_concurrency}, hosts={})",
405        results.len()
406    )?;
407    for h in results {
408        if h.ok {
409            writeln!(
410                out,
411                "  ok  {}  bytes={:?}  ms={:?}",
412                h.name, h.bytes, h.duration_ms
413            )?;
414        } else {
415            let err = h.error.as_deref().unwrap_or("error");
416            writeln!(out, "  ERR {}  {err}", h.name)?;
417        }
418    }
419    out.flush()
420}
421
422/// JSON event when the local tunnel listener comes up (GAP-SSH-IO-008).
423///
424/// # Errors
425/// Serialization or stdout I/O (including BrokenPipe).
426pub fn print_tunnel_listening_json(
427    vps: &str,
428    local_port: u16,
429    remote_host: &str,
430    remote_port: u16,
431    timeout_ms: u64,
432) -> io::Result<()> {
433    let v = TunnelListeningJson {
434        ok: true,
435        event: "tunnel_listening".into(),
436        vps: vps.to_string(),
437        local_port,
438        remote_host: remote_host.to_string(),
439        remote_port,
440        timeout_ms,
441    };
442    match json_wire::print_json_line(&v) {
443        Ok(()) => Ok(()),
444        Err(e) => {
445            report_json_serialize_error(&e);
446            Err(e)
447        }
448    }
449}
450