jiwa 0.3.0

Terminal text reveal animations — typewriter + per-grapheme fade-in + pulse. Renderer-agnostic: returns plain RGB so the caller maps to crossterm, ratatui, or its own ANSI writer.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
//! Best-effort sound playback for the `jiwa` binary.
//!
//! Issue #7: `--sound <PATH|URL>` plays a short sound each time a reveal
//! frame brings new non-whitespace graphemes into view, giving the
//! typewriter a "clack / blip" voice (typewriter, Dragon-Quest-style text
//! beep, etc.).
//!
//! Philosophy — the jiwa *binary* keeps **zero Cargo dependencies**:
//!
//! - We never decode or output audio ourselves. The sound *file* is
//!   user-supplied; playback shells out to whatever OS player exists
//!   (`ffplay` / `mpv` / `aplay` / `pw-cat`, or macOS `afplay`).
//! - URLs are fetched with `curl` (or `wget`) shelled out — no HTTP crate.
//! - Everything is **best-effort**: a missing file, missing fetcher,
//!   missing player, or a failed `spawn` is silent (at most one quiet
//!   stderr note at load time). The reveal animation always runs; the
//!   sound is purely additive.
//!
//! State discipline — jiwa holds **no surviving state**:
//!
//! - File / network I/O happens **once at load**: the bytes live in
//!   memory ([`Sound::bytes`]); graphemes never re-read or re-download.
//!   The URL cache lives in a temp dir whose cleanup is the OS's job
//!   (`tmpfiles` / reboot); jiwa does not manage or delete it.
//! - No long-lived (resident) player process: each [`Sound::play`] spawns
//!   a fresh short-lived player and never `wait`s on it, so the reveal
//!   never blocks. We do not keep a daemon/server player running. The
//!   only state retained is a handle to each spawned child so the *next*
//!   [`Sound::play`] can `try_wait` (non-blocking) to reap finished
//!   players — without that, fire-and-forget children would linger as
//!   zombies (defunct) until the jiwa process exits, consuming PIDs over
//!   a long `--read` session.
//!
//! Binary-only: not referenced by `lib.rs`.

use std::cell::RefCell;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::io::Write;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};

/// A resolved player: the command to run plus how it takes audio.
#[derive(Debug, Clone, PartialEq)]
pub struct Player {
    /// The executable to spawn (e.g. `ffplay`).
    cmd: String,
    /// Fixed arguments that precede the audio source.
    args: Vec<String>,
    /// How this player receives the audio bytes.
    feed: Feed,
}

/// How a [`Player`] is fed the audio.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Feed {
    /// Bytes are written to the child's stdin (e.g. `ffplay -i pipe:0`).
    Stdin,
    /// Player only accepts a file path (macOS `afplay`); jiwa writes the
    /// in-memory bytes to a temp file once and passes that path.
    Path,
}

/// A loaded sound: the audio bytes (read once) plus the detected player.
///
/// Not `Clone`: it owns the spawned [`Child`] handles (see `children`),
/// which are not cloneable. Nothing in jiwa needs a `Sound` clone — it is
/// loaded once and shared by reference.
#[derive(Debug)]
pub struct Sound {
    /// The in-memory audio bytes, streamed to each [`Feed::Stdin`] player.
    /// Empty for [`Feed::Path`] players: those replay from `path_for_player`
    /// instead, so holding the bytes in RAM too would just waste memory.
    bytes: Vec<u8>,
    player: Player,
    /// Temp path holding the bytes, for [`Feed::Path`] players (`afplay`).
    /// Written once at load; reused on every [`Sound::play`].
    path_for_player: Option<PathBuf>,
    /// Handles to player processes spawned by [`Sound::play`] but never
    /// `wait`ed on. Each `play` first `try_wait`s these (non-blocking) and
    /// drops the finished ones, so fire-and-forget children are reaped at
    /// the next play instead of accumulating as zombies. `RefCell` gives
    /// the interior mutability `play(&self)` needs.
    children: RefCell<Vec<Child>>,
}

/// Player candidates, in priority order. Stdin-capable players come first
/// (no temp file needed); the macOS path-only fallback (`afplay`) is last.
/// kako-jun runs Arch Linux, so the Linux players lead.
fn candidates() -> Vec<Player> {
    let s = |v: &str| v.to_string();
    let argv = |v: &[&str]| v.iter().map(|a| a.to_string()).collect();
    vec![
        // ffplay: read raw from stdin, no window, quit at EOF, quiet.
        Player {
            cmd: s("ffplay"),
            args: argv(&["-nodisp", "-autoexit", "-loglevel", "quiet", "-i", "pipe:0"]),
            feed: Feed::Stdin,
        },
        // mpv: no video, very quiet, read from stdin (`-`).
        Player {
            cmd: s("mpv"),
            args: argv(&["--no-video", "--really-quiet", "-"]),
            feed: Feed::Stdin,
        },
        // aplay: ALSA, quiet, WAV from stdin.
        Player {
            cmd: s("aplay"),
            args: argv(&["-q", "-"]),
            feed: Feed::Stdin,
        },
        // pw-cat: PipeWire playback from stdin (header-dependent; lower
        // priority, hence after aplay).
        Player {
            cmd: s("pw-cat"),
            args: argv(&["-p", "-"]),
            feed: Feed::Stdin,
        },
        // macOS afplay: file path only, no stdin. Last-resort fallback.
        Player {
            cmd: s("afplay"),
            args: Vec::new(),
            feed: Feed::Path,
        },
    ]
}

/// Load a sound from a local path or an `http(s)` URL.
///
/// Performs the *single* file/network read and detects a player, both at
/// startup. Returns `None` (after at most one quiet stderr note) on any
/// best-effort failure: unreadable file, failed/empty download, missing
/// fetcher, or no usable player. A `None` simply means silent reveal.
pub fn load(spec: &str) -> Option<Sound> {
    let bytes = if is_url(spec) {
        load_url(spec)?
    } else {
        match std::fs::read(spec) {
            Ok(b) if !b.is_empty() => b,
            Ok(_) => {
                note(&format!("sound file `{spec}` is empty; playing silently"));
                return None;
            }
            Err(e) => {
                note(&format!(
                    "cannot read sound `{spec}`: {e}; playing silently"
                ));
                return None;
            }
        }
    };

    let player = match detect_player() {
        Some(p) => p,
        None => {
            note("no audio player found (ffplay/mpv/aplay/pw-cat/afplay); playing silently");
            return None;
        }
    };

    // For path-only players, write the bytes to a temp file once now so
    // `play` can hand over the path without re-reading anything.
    let (bytes, path_for_player) = match player.feed {
        Feed::Path => {
            // The temp name is `jiwa-play-<hash(spec)>`: two jiwa runs
            // using the *same* source share this path. The contents are
            // identical (same source bytes), so a concurrent overwrite is
            // harmless — at worst both write the same data.
            let p = temp_path(&format!("jiwa-play-{}", hash_hex(spec)), spec);
            match std::fs::write(&p, &bytes) {
                // The bytes now live entirely in the temp file; drop the
                // in-memory copy (Feed::Path replays from the path, never
                // from `bytes`) so we do not double-hold the audio in RAM.
                Ok(()) => (Vec::new(), Some(p)),
                Err(e) => {
                    note(&format!(
                        "cannot stage sound for player: {e}; playing silently"
                    ));
                    return None;
                }
            }
        }
        Feed::Stdin => (bytes, None),
    };

    Some(Sound {
        bytes,
        player,
        path_for_player,
        children: RefCell::new(Vec::new()),
    })
}

impl Sound {
    /// Play the sound once. Spawns a fresh short-lived player and returns
    /// immediately without waiting — non-blocking so the reveal never
    /// stalls. All failures are ignored (best-effort).
    ///
    /// Before spawning we `try_wait` (non-blocking) the children from prior
    /// plays and drop the finished ones, reaping them so they do not linger
    /// as zombies. This bounds the retained handles to roughly the number of
    /// players still actively playing, instead of one per reveal frame.
    pub fn play(&self) {
        let mut children = self.children.borrow_mut();
        // Reap finished players: keep only those still running. `try_wait`
        // returning `Ok(Some(_))` (exited) or `Err(_)` reaps/clears the
        // child, so dropping it leaves no zombie; `Ok(None)` means it is
        // still playing and must be retained.
        children.retain_mut(|c| matches!(c.try_wait(), Ok(None)));

        let mut cmd = Command::new(&self.player.cmd);
        cmd.args(&self.player.args)
            .stdout(Stdio::null())
            .stderr(Stdio::null());

        match self.player.feed {
            Feed::Stdin => {
                cmd.stdin(Stdio::piped());
                if let Ok(mut child) = cmd.spawn() {
                    if let Some(mut stdin) = child.stdin.take() {
                        // Best-effort: ignore broken pipe etc. Dropping
                        // `stdin` closes it so the player sees EOF; we do
                        // not `wait` (fire-and-forget, non-blocking).
                        let _ = stdin.write_all(&self.bytes);
                    }
                    // Retain the handle so a later `play` can reap it.
                    children.push(child);
                }
            }
            Feed::Path => {
                cmd.stdin(Stdio::null());
                if let Some(path) = &self.path_for_player {
                    cmd.arg(path);
                    if let Ok(child) = cmd.spawn() {
                        children.push(child);
                    }
                }
            }
        }
    }
}

/// True if `spec` looks like an HTTP(S) URL we should fetch.
///
/// URL schemes are case-insensitive (RFC 3986 §3.1), so `HTTP://x` counts.
/// Only the *detection* lowercases; `load`/`load_url` pass the original
/// `spec` to curl/wget unchanged.
pub fn is_url(spec: &str) -> bool {
    let lower = spec.to_ascii_lowercase();
    lower.starts_with("http://") || lower.starts_with("https://")
}

/// Fetch a URL into the temp cache (once) and return its bytes.
///
/// If the cache file already exists and is non-empty, it is reused without
/// re-downloading. Otherwise `curl` (then `wget`) is shelled out exactly
/// once. Returns `None` (with a quiet note) on any failure.
fn load_url(url: &str) -> Option<Vec<u8>> {
    let path = temp_path(&format!("jiwa-sound-{}", hash_hex(url)), url);

    // Reuse an existing non-empty cache file: I/O happens once across runs
    // (until the OS reclaims the temp dir).
    if let Ok(b) = std::fs::read(&path) {
        if !b.is_empty() {
            return Some(b);
        }
    }

    if !fetch(url, &path) {
        note(&format!(
            "could not download sound `{url}` (need curl or wget); playing silently"
        ));
        return None;
    }

    match std::fs::read(&path) {
        Ok(b) if !b.is_empty() => Some(b),
        _ => {
            note(&format!(
                "downloaded sound `{url}` was empty; playing silently"
            ));
            None
        }
    }
}

/// Download `url` to `dest` with `curl`, falling back to `wget`. Returns
/// true only if a fetcher ran and exited successfully.
fn fetch(url: &str, dest: &std::path::Path) -> bool {
    let curl = Command::new("curl")
        .args(["-fsSL", "-o"])
        .arg(dest)
        .arg(url)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
    if matches!(curl, Ok(s) if s.success()) {
        return true;
    }

    let wget = Command::new("wget")
        .arg("-qO")
        .arg(dest)
        .arg(url)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
    matches!(wget, Ok(s) if s.success())
}

/// Detect the first available player from [`candidates`]. Probes each by
/// spawning `<cmd> --version` (stdio nulled); the first that spawns wins.
/// Run once at load.
pub fn detect_player() -> Option<Player> {
    candidates().into_iter().find(|p| player_exists(&p.cmd))
}

/// True if `cmd` can be spawned (i.e. exists on PATH). We run `--version`
/// and only care whether the process launches, not its exit code (some
/// players print version on stderr or exit non-zero).
fn player_exists(cmd: &str) -> bool {
    Command::new(cmd)
        .arg("--version")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .map(|mut child| {
            // Reap it so we don't leave a zombie; ignore the result.
            let _ = child.wait();
            true
        })
        .unwrap_or(false)
}

/// Build a per-user temp path: `<dir>/<stem>[.<ext>]`.
///
/// `dir` prefers `$XDG_RUNTIME_DIR` (per-user, cleaned on logout), then
/// `$TMPDIR`, then `/tmp`. A file extension is appended when one can be
/// guessed from `source` (the original path/URL), purely cosmetic — stdin
/// playback is extension-independent.
pub fn temp_path(stem: &str, source: &str) -> PathBuf {
    let dir = std::env::var_os("XDG_RUNTIME_DIR")
        .or_else(|| std::env::var_os("TMPDIR"))
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("/tmp"));

    let mut name = stem.to_string();
    if let Some(ext) = guess_extension(source) {
        name.push('.');
        name.push_str(&ext);
    }
    dir.join(name)
}

/// Guess a short file extension from a path/URL tail (e.g. `clack.wav` ->
/// `wav`). Returns `None` when there is no plausible alphanumeric
/// extension. Query strings / fragments on URLs are stripped first.
fn guess_extension(source: &str) -> Option<String> {
    // Drop URL query/fragment so `a.wav?x=1` still yields `wav`.
    let trimmed = source
        .split(['?', '#'])
        .next()
        .unwrap_or(source)
        .trim_end_matches('/');
    let tail = trimmed.rsplit(['/', '\\']).next().unwrap_or(trimmed);
    let (_, ext) = tail.rsplit_once('.')?;
    if ext.is_empty() || ext.len() > 5 || !ext.chars().all(|c| c.is_ascii_alphanumeric()) {
        return None;
    }
    Some(ext.to_ascii_lowercase())
}

/// Hash `s` (SipHash via std's [`DefaultHasher`]) to a lowercase hex u64.
/// Crate-free name for the temp cache file. The value is stable *within a
/// single build* (same input -> same hash for this binary), which is all
/// the temp-cache name needs; std does not promise [`DefaultHasher`] output
/// is stable across Rust versions, so do not persist or compare it elsewhere.
pub fn hash_hex(s: &str) -> String {
    let mut h = DefaultHasher::new();
    s.hash(&mut h);
    format!("{:016x}", h.finish())
}

/// Print one quiet, prefixed stderr note. Used sparingly at load time so
/// best-effort failures are discoverable without being noisy.
fn note(msg: &str) {
    eprintln!("jiwa: {msg}");
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn is_url_detects_http_and_https() {
        assert!(is_url("http://example.com/a.wav"));
        assert!(is_url("https://example.com/a.wav"));
        assert!(!is_url("/tmp/a.wav"));
        assert!(!is_url("a.wav"));
        assert!(!is_url("ftp://example.com/a.wav"));
        // A local path that merely contains "http" is not a URL.
        assert!(!is_url("./my-http-sound.wav"));
    }

    #[test]
    fn hash_hex_is_stable_and_16_hex_digits() {
        let a = hash_hex("https://example.com/clack.wav");
        let b = hash_hex("https://example.com/clack.wav");
        assert_eq!(a, b, "same input -> same hash");
        assert_eq!(a.len(), 16, "u64 as zero-padded hex is 16 chars");
        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
        // Different inputs almost certainly differ.
        assert_ne!(a, hash_hex("https://example.com/blip.wav"));
    }

    #[test]
    fn guess_extension_from_path_and_url() {
        assert_eq!(guess_extension("clack.wav").as_deref(), Some("wav"));
        assert_eq!(guess_extension("/a/b/blip.OGG").as_deref(), Some("ogg"));
        assert_eq!(
            guess_extension("https://x.test/s.mp3?token=1").as_deref(),
            Some("mp3")
        );
        assert_eq!(
            guess_extension("https://x.test/s.wav#frag").as_deref(),
            Some("wav")
        );
        // No extension, trailing slash, or implausible extension -> None.
        assert_eq!(guess_extension("https://x.test/sound"), None);
        assert_eq!(guess_extension("https://x.test/dir/"), None);
        assert_eq!(guess_extension("noext"), None);
        // Over-long or non-alphanumeric "extensions" are rejected.
        assert_eq!(guess_extension("a.verylongext"), None);
        assert_eq!(guess_extension("a.b_c"), None);
    }

    #[test]
    fn temp_path_uses_xdg_runtime_dir_when_set() {
        // We cannot safely mutate process env in parallel tests, so assert
        // the structural contract instead: the path ends with the expected
        // file name and lives under one of the documented base dirs.
        let p = temp_path("jiwa-sound-deadbeef", "https://x.test/clack.wav");
        let name = p.file_name().unwrap().to_string_lossy();
        assert_eq!(name, "jiwa-sound-deadbeef.wav");

        // Without a guessable extension the name is just the stem.
        let p2 = temp_path("jiwa-sound-cafe", "https://x.test/clack");
        assert_eq!(p2.file_name().unwrap().to_string_lossy(), "jiwa-sound-cafe");
    }

    #[test]
    fn candidates_are_in_documented_priority_order() {
        let cands = candidates();
        let names: Vec<&str> = cands.iter().map(|p| p.cmd.as_str()).collect();
        assert_eq!(names, ["ffplay", "mpv", "aplay", "pw-cat", "afplay"]);
        // Stdin players precede the path-only fallback.
        assert_eq!(cands.last().unwrap().feed, Feed::Path);
        assert!(cands[..cands.len() - 1]
            .iter()
            .all(|p| p.feed == Feed::Stdin));
    }

    #[test]
    fn ffplay_args_read_from_stdin_pipe() {
        let cands = candidates();
        let ffplay = &cands[0];
        assert_eq!(ffplay.cmd, "ffplay");
        assert_eq!(ffplay.feed, Feed::Stdin);
        // The argument vector must end at the stdin pipe source.
        assert!(ffplay.args.contains(&"pipe:0".to_string()));
        assert!(ffplay.args.contains(&"-nodisp".to_string()));
    }

    #[test]
    fn load_missing_local_path_is_none() {
        // Best-effort: a nonexistent file yields None, never a panic.
        assert!(load("/nonexistent/jiwa-test-sound.wav").is_none());
    }

    #[test]
    fn guess_extension_edge_cases() {
        // Trailing dot -> empty extension -> None.
        assert_eq!(guess_extension("a."), None);
        // Dotfile whose only "extension" is the whole tail (`bashrc`, len 6)
        // exceeds the 5-char cap -> None.
        assert_eq!(guess_extension(".bashrc"), None);
        // A leading-dot name with a short tail is still extracted.
        assert_eq!(guess_extension(".wav").as_deref(), Some("wav"));
        // Multi-dot: only the final segment is the extension.
        assert_eq!(guess_extension("a.tar.gz").as_deref(), Some("gz"));
        // Windows-style backslash path, case-folded.
        assert_eq!(guess_extension("C:\\x\\y.WAV").as_deref(), Some("wav"));
        // Empty / bare-dot sources have no extension.
        assert_eq!(guess_extension(""), None);
        assert_eq!(guess_extension("."), None);
        // Digits are alphanumeric, so a numeric extension is accepted.
        assert_eq!(guess_extension("a.123").as_deref(), Some("123"));
        // Length boundary: 5 chars OK, 6 chars rejected.
        assert_eq!(guess_extension("a.fffff").as_deref(), Some("fffff"));
        assert_eq!(guess_extension("a.ffffff"), None);
    }

    #[test]
    fn hash_hex_handles_empty_string() {
        // The empty string hashes to a deterministic 16-digit lowercase hex.
        let a = hash_hex("");
        assert_eq!(a, hash_hex(""), "deterministic for the empty string");
        assert_eq!(a.len(), 16);
        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn is_url_is_case_insensitive() {
        // URL schemes are case-insensitive (RFC 3986), so uppercase and
        // mixed-case http(s) schemes are recognized as URLs.
        assert!(is_url("HTTP://x"));
        assert!(is_url("HTTPS://X"));
        assert!(is_url("HtTpS://Example.com/a.wav"));
        // A non-http scheme is still not a URL we fetch.
        assert!(!is_url("ftp://example.com/a.wav"));
        // A local path that merely embeds "http" is not a URL.
        assert!(!is_url("./my-http-sound.wav"));
    }

    #[test]
    fn load_empty_local_file_is_none() {
        // A zero-byte local file is treated as "nothing to play" -> None.
        // Use a unique temp name (no env mutation, so parallel tests stay
        // safe) and clean it up afterwards.
        use std::sync::atomic::{AtomicU64, Ordering};
        static COUNTER: AtomicU64 = AtomicU64::new(0);
        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
        let path =
            std::env::temp_dir().join(format!("jiwa-empty-{}-{}.wav", std::process::id(), n));
        std::fs::write(&path, b"").expect("create empty temp file");
        let got = load(path.to_str().unwrap());
        let _ = std::fs::remove_file(&path);
        assert!(got.is_none(), "empty local file must load as None");
    }
}