|
1 | 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | +// Rust guideline compliant 2026-03-30 |
2 | 3 | //! Single-line failure diagnostic for every Gitway binary. |
3 | 4 | //! |
4 | 5 | //! When a Gitway binary runs and fails in human (non-JSON) mode, one |
5 | | -//! [`emit`] or [`emit_for`] call writes a logfmt-style record to stderr: |
| 6 | +//! [`emit`] / [`emit_for`] / [`emit_for_with_config_sources`] call writes |
| 7 | +//! a logfmt-style record to stderr: |
6 | 8 | //! |
7 | 9 | //! ```text |
8 | | -//! gitway diag ts=2026-04-22T18:43:11Z pid=12345 code=4 reason=PERMISSION_DENIED argv=["gitway", "git@github.com", "git-upload-pack", "'org/repo.git'"] |
| 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'"] |
9 | 11 | //! ``` |
10 | 12 | //! |
11 | 13 | //! The point is to turn silent `exit 128` failures — the opaque code git |
12 | 14 | //! reports when `core.sshCommand` fails — into a single grep-able line |
13 | 15 | //! that carries enough context to triage: ISO 8601 timestamp, PID, argv, |
14 | | -//! exit code, and a short error reason. |
| 16 | +//! exit code, error reason, and (when relevant) the `ssh_config(5)` |
| 17 | +//! file(s) that were consulted (NFR-24, M12.8). |
15 | 18 | //! |
16 | 19 | //! JSON mode already carries `timestamp` and `command` in its structured |
17 | 20 | //! `{"error": {...}}` blob, so callers should skip this helper on that |
18 | 21 | //! path. Stdout is always left untouched (SFRS Rule 1) — the diagnostic |
19 | 22 | //! writes exclusively to stderr. |
20 | 23 |
|
| 24 | +use std::path::PathBuf; |
| 25 | + |
21 | 26 | use crate::error::AnvilError; |
22 | 27 | use crate::time::now_iso8601; |
23 | 28 |
|
24 | 29 | /// Emits the single-line diagnostic record with an explicit exit code and |
25 | 30 | /// a reason string. Use this from the shim binaries (`gitway-keygen`, |
26 | 31 | /// `gitway-add`) where the reason codes are selected from a local static |
27 | | -/// table; use [`emit_for`] when a [`AnvilError`] is already in hand. |
| 32 | +/// table; use [`emit_for`] when an [`AnvilError`] is already in hand. |
28 | 33 | pub 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. |
| 39 | +pub 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`). |
| 56 | +pub fn emit_for_with_config_sources(err: &AnvilError, config_sources: &[PathBuf]) { |
| 57 | + emit_inner(err.exit_code(), err.error_code(), config_sources); |
| 58 | +} |
| 59 | + |
| 60 | +fn emit_inner(code: u32, reason: &str, config_sources: &[PathBuf]) { |
29 | 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 | + }; |
30 | 74 | eprintln!( |
31 | | - "gitway diag ts={ts} pid={pid} code={code} reason={reason} argv={argv:?}", |
| 75 | + "gitway diag ts={ts} pid={pid} code={code} reason={reason}{extra} argv={argv:?}", |
32 | 76 | ts = now_iso8601(), |
33 | 77 | pid = std::process::id(), |
34 | 78 | ); |
35 | 79 | } |
36 | | - |
37 | | -/// Emits the diagnostic record for a [`AnvilError`], reusing the error's |
38 | | -/// mapped exit code and string error class. |
39 | | -pub fn emit_for(err: &AnvilError) { |
40 | | - emit(err.exit_code(), err.error_code()); |
41 | | -} |
|
0 commit comments