Skip to main content

ssh_cli/output/
text.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP: human-readable VPS/exec output (extracted from output monólito).
3#![forbid(unsafe_code)]
4//! Text-mode formatters for VPS CRUD and one-shot execution.
5
6use super::emit::{is_quiet, write_line_human};
7use crate::masking::mask;
8use crate::ssh::ExecutionOutput;
9use crate::vps::model::VpsRecord;
10use secrecy::ExposeSecret;
11use std::io::{self, Write};
12
13/// Prints the doctor report as human text (GAP-SSH-IO-005).
14// B3: kept deliberately. The doctor report is a flat, stable agent surface — the
15// text renderer mirrors the JSON field set one-for-one. Wrapping it in a context
16// struct would create a second definition of the same contract that could drift
17// from the wire DTO, which is a worse failure than a long parameter list.
18#[allow(clippy::too_many_arguments)]
19pub fn print_doctor_text(
20    layer: &str,
21    config_path: &str,
22    exists: bool,
23    perms: &str,
24    schema_version: u32,
25    hosts: usize,
26    known_hosts: &str,
27    active_file: &str,
28    secrets_at_rest: &str,
29    secrets_key_source: &str,
30    secrets_key_file: &str,
31    plaintext_opt_out: bool,
32) {
33    if is_quiet() {
34        return;
35    }
36    let stdout = io::stdout();
37    let mut out = io::BufWriter::new(stdout.lock());
38    let opt_out = if plaintext_opt_out { "yes" } else { "no" };
39    let _ = (|| -> io::Result<()> {
40        writeln!(out, "Winning layer:   {layer}")?;
41        writeln!(out, "Config path:      {config_path}")?;
42        writeln!(out, "Exists:           {exists}")?;
43        writeln!(out, "Permissions:      {perms}")?;
44        writeln!(out, "Schema:           {schema_version}")?;
45        writeln!(out, "Hosts:            {hosts}")?;
46        writeln!(out, "known_hosts:      {known_hosts}")?;
47        writeln!(out, "active file:      {active_file}")?;
48        writeln!(
49            out,
50            "Secrets at-rest:  {secrets_at_rest} (key source: {secrets_key_source})"
51        )?;
52        writeln!(out, "Secrets key file: {secrets_key_file}")?;
53        writeln!(out, "Plaintext opt-out: {opt_out}")?;
54        writeln!(out, "Telemetry:        disabled")?;
55        out.flush()
56    })();
57}
58
59/// Prints the VPS list as masked text.
60///
61/// Streams rows with `writeln!` under one stdout lock (G-MAC-02).
62pub fn print_list_text(records: &[VpsRecord]) {
63    if is_quiet() {
64        return;
65    }
66    if records.is_empty() {
67        write_line_human(&crate::i18n::t(crate::i18n::Message::VpsRegistryEmpty));
68        return;
69    }
70
71    let stdout = io::stdout();
72    let mut out = io::BufWriter::new(stdout.lock());
73    let _ = (|| -> io::Result<()> {
74        writeln!(
75            out,
76            "{:<20} {:<30} {:<6} {:<15} {:<20}",
77            "NAME", "HOST", "PORT", "USER", "PASSWORD"
78        )?;
79        for r in records {
80            writeln!(
81                out,
82                "{:<20} {:<30} {:<6} {:<15} {:<20}",
83                r.name,
84                r.host,
85                r.port,
86                r.username,
87                mask(r.password.expose_secret())
88            )?;
89        }
90        out.flush()
91    })();
92}
93
94/// Prints the VPS list as masked JSON.
95///
96/// # Errors
97pub fn print_details_text(r: &VpsRecord) {
98    if is_quiet() {
99        return;
100    }
101    // GAP-SSH-JSON-001: empty password (key-only) does not fake a masked value.
102    // mask() is &'static str (zero-alloc); keep both branches as &str.
103    let password = if r.password.expose_secret().is_empty() {
104        "(not set)"
105    } else {
106        mask(r.password.expose_secret())
107    };
108    let key_path_owned = r.key_path.as_ref().map(|k| k.to_string_lossy_owned());
109    let key_path = key_path_owned.as_deref().unwrap_or("(not set)");
110    let sudo = r
111        .sudo_password
112        .as_ref()
113        .map_or("(not set)", |s| mask(s.expose_secret()));
114    let su = r
115        .su_password
116        .as_ref()
117        .map_or("(not set)", |s| mask(s.expose_secret()));
118
119    let stdout = io::stdout();
120    let mut out = io::BufWriter::new(stdout.lock());
121    let _ = (|| -> io::Result<()> {
122        writeln!(out, "Name:            {}", r.name)?;
123        writeln!(out, "Host:           {}", r.host)?;
124        writeln!(out, "Port:            {}", r.port)?;
125        writeln!(out, "User:            {}", r.username)?;
126        writeln!(out, "Password:       {password}")?;
127        writeln!(out, "Key path:       {key_path}")?;
128        writeln!(out, "Sudo password:  {sudo}")?;
129        writeln!(out, "Su password:    {su}")?;
130        writeln!(out, "Timeout (ms):   {}", r.timeout_ms)?;
131        writeln!(out, "Max cmd chars:  {}", r.max_command_chars.wire())?;
132        writeln!(out, "Max out chars:  {}", r.max_output_chars.wire())?;
133        writeln!(out, "Disable sudo:   {}", r.disable_sudo)?;
134        writeln!(out, "Schema version: {}", r.schema_version)?;
135        writeln!(out, "Added at:        {}", r.added_at)?;
136        out.flush()
137    })();
138}
139
140/// Prints a single VPS record as masked JSON.
141///
142/// # Errors
143pub fn print_execution_output(output: &ExecutionOutput) {
144    let stdout = io::stdout();
145    let mut out = io::BufWriter::new(stdout.lock());
146    let _ = (|| -> io::Result<()> {
147        writeln!(out, "--- stdout ---")?;
148        if output.stdout.is_empty() {
149            writeln!(out, "(empty)")?;
150        } else {
151            writeln!(out, "{}", output.stdout)?;
152        }
153        writeln!(out, "--- stderr ---")?;
154        if output.stderr.is_empty() {
155            writeln!(out, "(empty)")?;
156        } else {
157            writeln!(out, "{}", output.stderr)?;
158        }
159        match output.exit_code {
160            Some(code) => writeln!(
161                out,
162                "--- exit code: {} ({}ms) ---",
163                code, output.duration_ms
164            )?,
165            None => writeln!(out, "--- exit code: N/A ({}ms) ---", output.duration_ms)?,
166        }
167        // G-IO-04: technical English only on stdout (agent contract).
168        if output.truncated_stdout {
169            writeln!(out, "(stdout was truncated)")?;
170        }
171        if output.truncated_stderr {
172            writeln!(out, "(stderr was truncated)")?;
173        }
174        out.flush()
175    })();
176}
177
178/// Prints SSH command execution output as JSON.
179///
180/// # Errors
181pub fn print_health_check(name: &str, latency_ms: u64) {
182    if is_quiet() {
183        return;
184    }
185    let msg = crate::i18n::t(crate::i18n::Message::HealthCheckOk {
186        name: name.to_string(),
187    });
188    let stdout = io::stdout();
189    let mut out = io::BufWriter::new(stdout.lock());
190    let _ = (|| -> io::Result<()> {
191        writeln!(out, "{msg}")?;
192        writeln!(out, "  latency: {latency_ms}ms")?;
193        out.flush()
194    })();
195}