crates/terminal-garden/src/main.rs
149 lines · 5425 bytes
1//! # terminal-garden
2//!
3//! The first face of the bus: a renderer, not a collector.
4//! Git is the cleanest signal in the suite — discrete, unambiguous, no privacy
5//! questions — so the garden proves the bus before anything reads the shell.
6//!
7//! Like every renderer, the garden is thin: connect to the socket → read the
8//! git aggregates → fold them into per-repo plots → render. It never touches a
9//! sensor. The mapping (commits → growth, staleness → wilt) lives in the
10//! `terminal-garden` library so it can be unit-tested; this binary is just the
11//! subscriber loop.
12//!
13//! Usage:
14//! ```text
15//! terminal-garden [--socket <path>]
16//! terminal-garden --help | --version
17//! ```
18//! Socket defaults to `$XDG_RUNTIME_DIR/signald.sock`, falling back to
19//! `~/.local/state/signald/sock`.
20
21use std::collections::BTreeMap;
22use std::io::BufReader;
23use std::os::unix::net::UnixStream;
24use std::path::PathBuf;
25
26use signal_schema::{wire, Signal, SignalName};
27use terminal_garden::{plots_from_signals, render};
28
29/// The `name`s the garden cares about: the git aggregates.
30const SUBSCRIBE: &[SignalName] = &[
31 SignalName::CommitsWindow,
32 SignalName::CommitsToday,
33 SignalName::BranchCount,
34 SignalName::DaysSinceLastCommit,
35];
36
37fn main() {
38 let socket = parse_socket();
39 if let Err(e) = run(&socket) {
40 eprintln!("terminal-garden: could not read signald at {}: {e}", socket.display());
41 eprintln!("terminal-garden: is signald running? (signald --socket {})", socket.display());
42 std::process::exit(1);
43 }
44}
45
46/// Connect and render live: the daemon replays the last-value cache on connect,
47/// then streams updates. We keep the latest value of each metric (keyed by name
48/// and repo tag) and re-render the garden on every frame. `Ok(())` is a clean EOF
49/// (the daemon closed the stream). Frames this build cannot decode are skipped,
50/// so an older renderer keeps working against a newer daemon.
51fn run(socket: &PathBuf) -> std::io::Result<()> {
52 let stream = UnixStream::connect(socket)?;
53 let mut reader = BufReader::new(stream);
54
55 // Keyed by (metric name, repo tag) so per-repo signals coexist and updates
56 // replace prior values rather than accumulating.
57 let mut latest: BTreeMap<(u8, Option<String>), Signal> = BTreeMap::new();
58 loop {
59 let sig = match wire::read_frame(&mut reader)? {
60 wire::Frame::Signal(sig) => sig,
61 // A record this build does not understand: a newer daemon, or a
62 // metric appended after this renderer was built. Keep rendering.
63 wire::Frame::Skipped => continue,
64 wire::Frame::Eof => break,
65 };
66 if !SUBSCRIBE.contains(&sig.name) {
67 continue;
68 }
69 let key = (sig.name.to_u8(), sig.tag.as_ref().map(|t| t.as_str().to_string()));
70 latest.insert(key, sig);
71
72 let signals: Vec<Signal> = latest.values().cloned().collect();
73 let plots = plots_from_signals(&signals);
74 // Clear + home so the garden redraws in place as signals change.
75 print!("\x1b[2J\x1b[H{}", render(&plots));
76 }
77 Ok(())
78}
79
80const USAGE: &str = "\
81Usage: terminal-garden [--socket <path>]
82
83 --socket <path> signald's socket (default $XDG_RUNTIME_DIR/signald.sock,
84 else ~/.local/state/signald/sock)
85 -h, --help print this and exit
86 -V, --version print the version and exit
87";
88
89fn parse_socket() -> PathBuf {
90 let mut args = std::env::args().skip(1);
91 while let Some(arg) = args.next() {
92 match arg.as_str() {
93 "--help" | "-h" => {
94 print!("{USAGE}");
95 std::process::exit(0);
96 }
97 "--version" | "-V" => {
98 println!("terminal-garden {}", env!("CARGO_PKG_VERSION"));
99 std::process::exit(0);
100 }
101 _ => {}
102 }
103 if arg == "--socket" {
104 if let Some(path) = args.next() {
105 return PathBuf::from(path);
106 }
107 }
108 }
109 default_socket_path()
110}
111
112/// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`.
113///
114/// Split from the environment so it can be tested without mutating it. The
115/// same resolution is duplicated in the other binary and in
116/// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one
117/// place they are written down, and the tests below pin them to it.
118fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf {
119 if let Some(dir) = xdg_runtime_dir {
120 return PathBuf::from(dir).join("signald.sock");
121 }
122 PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock")
123}
124
125fn default_socket_path() -> PathBuf {
126 socket_path_from(
127 std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
128 std::env::var("HOME").ok().as_deref(),
129 )
130}
131
132#[cfg(test)]
133mod path_tests {
134 use super::*;
135
136 /// Pinned to the README "Paths" table. If this changes, the table and
137 /// `shell-hooks/signald-hooks.zsh` change with it.
138 #[test]
139 fn socket_default_follows_xdg_then_home() {
140 assert_eq!(
141 socket_path_from(Some("/run/user/501"), Some("/Users/x")),
142 PathBuf::from("/run/user/501/signald.sock")
143 );
144 assert_eq!(
145 socket_path_from(None, Some("/Users/x")),
146 PathBuf::from("/Users/x/.local/state/signald/sock")
147 );
148 }
149}