Skip to main content

anvil_ssh/
diagnostic.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3//! Single-line failure diagnostic for every Gitway binary.
4//!
5//! When a Gitway binary runs and fails in human (non-JSON) mode, one
6//! [`emit`] / [`emit_for`] / [`emit_for_with_config_sources`] call writes
7//! a logfmt-style record to stderr:
8//!
9//! ```text
10//! gitway diag ts=2026-04-22T18:43:11Z pid=12345 code=4 reason=PERMISSION_DENIED config_source=~/.ssh/config,/etc/ssh/ssh_config argv=["gitway", "git@github.com", "git-upload-pack", "'org/repo.git'"]
11//! ```
12//!
13//! The point is to turn silent `exit 128` failures — the opaque code git
14//! reports when `core.sshCommand` fails — into a single grep-able line
15//! that carries enough context to triage: ISO 8601 timestamp, PID, argv,
16//! exit code, error reason, and (when relevant) the `ssh_config(5)`
17//! file(s) that were consulted (NFR-24, M12.8).
18//!
19//! JSON mode already carries `timestamp` and `command` in its structured
20//! `{"error": {...}}` blob, so callers should skip this helper on that
21//! path.  Stdout is always left untouched (SFRS Rule 1) — the diagnostic
22//! writes exclusively to stderr.
23
24use std::path::PathBuf;
25
26use crate::error::AnvilError;
27use crate::time::now_iso8601;
28
29/// Emits the single-line diagnostic record with an explicit exit code and
30/// a reason string.  Use this from the shim binaries (`gitway-keygen`,
31/// `gitway-add`) where the reason codes are selected from a local static
32/// table; use [`emit_for`] when an [`AnvilError`] is already in hand.
33pub fn emit(code: u32, reason: &str) {
34    emit_inner(code, reason, &[]);
35}
36
37/// Emits the diagnostic record for an [`AnvilError`], reusing the error's
38/// mapped exit code and string error class.
39pub fn emit_for(err: &AnvilError) {
40    emit_inner(err.exit_code(), err.error_code(), &[]);
41}
42
43/// Like [`emit_for`], plus a `config_source=` field listing the
44/// `ssh_config(5)` files that were consulted during this invocation.
45///
46/// `config_sources` should be the deduplicated list of files the
47/// resolver attempted to read (typically `~/.ssh/config` and, on Unix,
48/// `/etc/ssh/ssh_config`).  An empty slice produces a line identical to
49/// [`emit_for`] — no `config_source=` field is emitted.
50///
51/// This is the M12.8 entry point for NFR-24: callers that successfully
52/// or unsuccessfully consulted `ssh_config` should pass that fact down
53/// to the diagnostic so triage tooling can attribute behavior to the
54/// right file.  The Gitway CLI does this around its top-level
55/// [`emit_for`]-equivalent call site (`gitway-cli/src/main.rs`).
56pub fn emit_for_with_config_sources(err: &AnvilError, config_sources: &[PathBuf]) {
57    emit_inner(err.exit_code(), err.error_code(), config_sources);
58}
59
60fn emit_inner(code: u32, reason: &str, config_sources: &[PathBuf]) {
61    let argv: Vec<String> = std::env::args().collect();
62    let extra = if config_sources.is_empty() {
63        String::new()
64    } else {
65        let joined: Vec<String> = config_sources
66            .iter()
67            .map(|p| p.to_string_lossy().into_owned())
68            .collect();
69        // Field name is logfmt-style `key=value` like the others.  The
70        // value is a comma-separated list of paths; commas in paths are
71        // exceedingly rare and not worth quoting for in this MVP.
72        format!(" config_source={}", joined.join(","))
73    };
74    eprintln!(
75        "gitway diag ts={ts} pid={pid} code={code} reason={reason}{extra} argv={argv:?}",
76        ts = now_iso8601(),
77        pid = std::process::id(),
78    );
79}