agentmux_launcher/
splash_info.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Identity shown in the splash footer: `user@host` and `v<version>` (+ a dev
5//! label on non-stable builds). Gathered once in the launcher and handed to each
6//! platform's splash backend, so the three render identical content.
7//!
8//! All sourcing is dependency-light and best-effort — a missing field falls back
9//! to a placeholder and never blocks or crashes the splash.
10
11use std::process::Command;
12
13pub struct SplashInfo {
14    pub user: String,
15    pub host: String,
16    pub version: String,
17    /// `Some` only on non-stable builds (dev / explicit channel); `None` on a
18    /// stable release (footer then shows just the version).
19    pub dev_label: Option<String>,
20}
21
22impl SplashInfo {
23    pub fn gather() -> Self {
24        SplashInfo {
25            user: username(),
26            host: hostname(),
27            version: env!("CARGO_PKG_VERSION").to_string(),
28            dev_label: dev_label(),
29        }
30    }
31
32    /// The footer lines to render (each clamped to `max_chars`):
33    /// line 1 = `user@host`, line 2 = `v<version>` (+ ` (<dev_label>)`).
34    pub fn footer_lines(&self, max_chars: usize) -> Vec<String> {
35        let l1 = ellipsize(&format!("{}@{}", self.user, self.host), max_chars);
36        let l2 = match &self.dev_label {
37            Some(d) => format!("v{} ({})", self.version, d),
38            None => format!("v{}", self.version),
39        };
40        vec![l1, ellipsize(&l2, max_chars)]
41    }
42}
43
44fn username() -> String {
45    for k in ["USER", "USERNAME", "LOGNAME"] {
46        if let Ok(v) = std::env::var(k) {
47            let v = sanitize(v.trim());
48            if !v.is_empty() {
49                return v;
50            }
51        }
52    }
53    cmd_first_line("whoami", &[])
54        .map(|s| sanitize(s.trim()))
55        .filter(|s| !s.is_empty())
56        .unwrap_or_else(|| "user".into())
57}
58
59fn hostname() -> String {
60    // Prefer env (no subprocess); COMPUTERNAME on Windows, HOSTNAME elsewhere.
61    for k in ["HOSTNAME", "COMPUTERNAME", "HOST"] {
62        if let Ok(v) = std::env::var(k) {
63            let v = short_host(v.trim());
64            if !v.is_empty() {
65                return v;
66            }
67        }
68    }
69    cmd_first_line("hostname", &[])
70        .map(|s| short_host(s.trim()))
71        .filter(|s| !s.is_empty())
72        .unwrap_or_else(|| "host".into())
73}
74
75/// `None` on a stable release; otherwise a short badge.
76/// - `AGENTMUX_DEV=1` (set by `task dev`) → `dev`.
77/// - explicit `AGENTMUX_CHANNEL` other than `stable` → that channel.
78/// (Baked per-build local channels without an env override are treated as stable
79/// for the footer in v1 — see SPEC §3 "dev_label".)
80fn dev_label() -> Option<String> {
81    if std::env::var("AGENTMUX_DEV")
82        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
83        .unwrap_or(false)
84    {
85        return Some("dev".into());
86    }
87    if let Ok(ch) = std::env::var("AGENTMUX_CHANNEL") {
88        let ch = sanitize(ch.trim());
89        if !ch.is_empty() && ch != "stable" {
90            return Some(ch);
91        }
92    }
93    None
94}
95
96/// First non-empty stdout line of `cmd args`, trimmed. `None` on any failure.
97fn cmd_first_line(cmd: &str, args: &[&str]) -> Option<String> {
98    let out = Command::new(cmd).args(args).output().ok()?;
99    if !out.status.success() {
100        return None;
101    }
102    String::from_utf8_lossy(&out.stdout)
103        .lines()
104        .map(|l| l.trim().to_string())
105        .find(|l| !l.is_empty())
106}
107
108/// Keep printable ASCII (0x20..0x7E); replace anything else with '?'. The splash
109/// font only carries ASCII, so this keeps the footer legible for odd names.
110fn sanitize(s: &str) -> String {
111    s.chars()
112        .map(|c| if (' '..='~').contains(&c) { c } else { '?' })
113        .collect()
114}
115
116/// Short hostname: first dot-segment, sanitized (`devbox.local` → `devbox`).
117fn short_host(s: &str) -> String {
118    sanitize(s.split('.').next().unwrap_or(s))
119}
120
121/// Middle-ellipsize `s` to at most `max` chars so the footer never widens the card.
122fn ellipsize(s: &str, max: usize) -> String {
123    let chars: Vec<char> = s.chars().collect();
124    if chars.len() <= max {
125        return s.to_string();
126    }
127    if max <= 3 {
128        return chars.iter().take(max).collect();
129    }
130    let keep = max - 3;
131    let left = keep.div_ceil(2);
132    let right = keep - left;
133    let head: String = chars[..left].iter().collect();
134    let tail: String = chars[chars.len() - right..].iter().collect();
135    format!("{head}...{tail}")
136}