insmaller 0.6.2

Config-driven installer: describe install steps in TOML and run them from one binary
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
//! TTY-aware `InputResolver` for the CLI binary. Layers in front of a
//! fallback (always `EnvResolver` in prod) so `prompt`/`input` steps in a
//! task can read stdin on an attached terminal — masking the value for
//! `secret = true` — while non-interactive runs keep the env-only contract
//! that makes them structurally non-blocking. The TTY check + environment
//! lookup + line read are pushed behind a small `InteractiveIo` trait so
//! the resolver is unit-testable without a real terminal.

use crossterm::event::{
    DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
    KeyModifiers,
};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use insmaller_core::{InputResolver, PromptSpec, ResolvedInput};
use std::io::{IsTerminal, Write};
use std::sync::Mutex;

/// Process-global serializer for interactive reads. crossterm's raw mode is
/// a process resource; two parallel tasks each entering a `prompt` step
/// must not race `enable_raw_mode`/`event::read`/`disable_raw_mode` against
/// each other or interleave keystrokes between two `buf`s. Lock duration is
/// bounded by the user's typing speed — short enough that a sync `Mutex`
/// (no async holding) is the right primitive.
static INTERACTIVE_LOCK: Mutex<()> = Mutex::new(());

/// Outcome of an interactive read.
pub enum InteractiveLine {
    /// A line was entered (possibly empty).
    Line(String),
    /// The user cancelled (Ctrl+C / Ctrl+D / Esc).
    Cancel,
    /// stdin is not a TTY — caller should defer to the fallback resolver.
    NoTty,
}

/// Injectable I/O surface — production uses the real terminal, tests pass a
/// fake so they don't need a PTY.
pub trait InteractiveIo: Send + Sync {
    /// True when interactive prompting is safe. Requires BOTH stdin (to read
    /// the user's value) AND stdout (where the prompt is written) to be
    /// terminals — stdout-redirected runs (`> log`) must defer to the env
    /// fallback so the user doesn't type blind.
    fn is_tty(&self) -> bool;
    /// Process env lookup (resolver path 1: env always wins on hit).
    fn env(&self, key: &str) -> Option<String>;
    /// Display `message` and read a line. `secret = true` ⇒ mask with `*`.
    fn read_line(&self, message: &str, secret: bool) -> std::io::Result<InteractiveLine>;
}

/// Production I/O: `std::io::stdin().is_terminal()` + `std::io::stdout().is_terminal()`,
/// `std::env::var`, plus a crossterm-driven masked line reader for secret prompts.
pub struct RealIo;

impl InteractiveIo for RealIo {
    fn is_tty(&self) -> bool {
        std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
    }
    fn env(&self, key: &str) -> Option<String> {
        // Shared with EnvResolver so the empty-is-absent rule has one source.
        insmaller_core::env_nonempty(key)
    }
    fn read_line(&self, message: &str, secret: bool) -> std::io::Result<InteractiveLine> {
        if !self.is_tty() {
            return Ok(InteractiveLine::NoTty);
        }
        // The lock-wait and the human-speed read both block. `resolve()` is a
        // sync trait method called from inside an async step on a tokio worker
        // thread (PromptProcessor::run), so a naked blocking read parks a
        // worker — starving step-timeout timers and any parallel task in the
        // same wave. Run the whole critical section under `block_in_place` so
        // tokio can move other tasks off this worker while we wait on the lock
        // and on the user. (block_in_place panics on a current-thread runtime
        // or off-runtime, hence the guard; tests drive FakeIo, not RealIo.)
        maybe_block_in_place(|| self.read_line_blocking(message, secret))
    }
}

impl RealIo {
    /// The actual blocking read, factored out so `read_line` can wrap it in
    /// `block_in_place`. Holds `INTERACTIVE_LOCK` for the full duration so two
    /// concurrent prompts on the one shared terminal serialize.
    fn read_line_blocking(&self, message: &str, secret: bool) -> std::io::Result<InteractiveLine> {
        // Poison is recoverable: we don't care about a prior holder's state.
        let _guard = INTERACTIVE_LOCK
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let mut out = std::io::stdout();
        // Render the prompt before either path (raw mode silences echo, so
        // the prompt must precede the mode switch for the non-secret case to
        // look sane after rendering).
        write!(out, "{message} ")?;
        out.flush()?;
        if secret {
            read_masked_line()
        } else {
            let mut s = String::new();
            std::io::stdin().read_line(&mut s)?;
            let trimmed = s.trim_end_matches(['\r', '\n']).to_string();
            Ok(InteractiveLine::Line(trimmed))
        }
    }
}

/// Run `f` under `tokio::task::block_in_place` when on a multi-thread runtime
/// (lets the scheduler relocate other tasks while this worker blocks); run it
/// directly otherwise. `block_in_place` panics off-runtime and on a
/// current-thread runtime, so both are checked first.
fn maybe_block_in_place<T>(f: impl FnOnce() -> T) -> T {
    use tokio::runtime::{Handle, RuntimeFlavor};
    match Handle::try_current() {
        Ok(h) if h.runtime_flavor() == RuntimeFlavor::MultiThread => {
            tokio::task::block_in_place(f)
        }
        _ => f(),
    }
}

/// RAII for crossterm raw mode. Drop runs on every unwind path (`?`-return,
/// panic, early `return`) — the inline-disable pattern this replaces only
/// caught `?` and leaked the terminal on panic. Mirrors `tui.rs::TermGuard`.
struct RawModeGuard;

impl RawModeGuard {
    fn enable() -> std::io::Result<Self> {
        enable_raw_mode()?;
        Ok(Self)
    }
}

impl Drop for RawModeGuard {
    fn drop(&mut self) {
        let _ = disable_raw_mode();
    }
}

/// RAII for crossterm bracketed paste mode. When enabled, terminals send
/// pasted text as a single `Event::Paste(String)` instead of synthesizing
/// per-character key events — lets a pasted secret arrive atomically
/// instead of leaking the second line into the next prompt.
struct BracketedPasteGuard;

impl BracketedPasteGuard {
    fn enable() -> std::io::Result<Self> {
        crossterm::execute!(std::io::stdout(), EnableBracketedPaste)?;
        Ok(Self)
    }
}

impl Drop for BracketedPasteGuard {
    fn drop(&mut self) {
        let _ = crossterm::execute!(std::io::stdout(), DisableBracketedPaste);
    }
}

/// Read a line in raw mode, echoing `*` per character. Backspace pops a char
/// (and the `*`); Enter ends the line; Ctrl+C / Ctrl+D / Esc cancels. Other
/// Ctrl+letter chords are silently dropped (never pushed as literals). On
/// terminals supporting bracketed paste, a pasted payload arrives atomically
/// via `Event::Paste` and is appended in one shot (no leakage of trailing
/// lines into the next read). KeyEventKind is filtered to Press|Repeat so
/// Windows legacy console — which emits both Press and Release — doesn't
/// double-count keystrokes.
fn read_masked_line() -> std::io::Result<InteractiveLine> {
    let _raw = RawModeGuard::enable()?;
    // LOAD-BEARING: `_paste_guard` must live until the end of this function.
    // Its Drop emits DisableBracketedPaste; dropping it early (e.g. rewriting
    // to `let _ = …` or deleting the "unused" binding) turns paste mode off
    // before the read loop and reintroduces the multi-line-paste leak. The
    // `.ok()` is deliberate: a terminal without bracketed-paste support just
    // falls back to per-key events, which the loop still handles correctly.
    let _paste_guard = BracketedPasteGuard::enable().ok();
    let mut buf = String::new();
    let mut out = std::io::stdout();
    loop {
        match crossterm::event::read()? {
            Event::Key(KeyEvent {
                code,
                modifiers,
                kind: KeyEventKind::Press | KeyEventKind::Repeat,
                ..
            }) => {
                let ctrl = modifiers.contains(KeyModifiers::CONTROL);
                match masked_key(&mut buf, code, ctrl) {
                    KeyEffect::Submit => {
                        writeln!(out)?;
                        out.flush()?;
                        return Ok(InteractiveLine::Line(buf));
                    }
                    KeyEffect::Cancel => {
                        writeln!(out)?;
                        out.flush()?;
                        return Ok(InteractiveLine::Cancel);
                    }
                    KeyEffect::Echo(s) => {
                        write!(out, "{s}")?;
                        out.flush()?;
                    }
                    KeyEffect::Ignore => {}
                }
            }
            Event::Paste(s) => {
                let kept = paste_filter(&s);
                for _ in kept.chars() {
                    write!(out, "*")?;
                }
                buf.push_str(&kept);
                out.flush()?;
            }
            _ => {}
        }
    }
}

/// What one key press does in the masked reader.
enum KeyEffect {
    /// Echo this string (`*`, a backspace erase, or nothing) and keep reading.
    Echo(&'static str),
    /// Line complete (Enter).
    Submit,
    /// User cancelled (Esc / Ctrl+C / Ctrl+D).
    Cancel,
    /// No echo, keep reading.
    Ignore,
}

/// Pure per-key state transition for the masked reader — mutates `buf` for
/// character/backspace keys and returns the echo + control-flow decision.
/// Extracted from the event loop so the line-editing rules (Ctrl-chord
/// dropping, Ctrl+D-as-cancel, backspace-on-empty) are unit-testable without
/// a real terminal. `ctrl` = the CONTROL modifier was held.
fn masked_key(buf: &mut String, code: KeyCode, ctrl: bool) -> KeyEffect {
    match code {
        KeyCode::Enter => KeyEffect::Submit,
        KeyCode::Esc => KeyEffect::Cancel,
        // Ctrl+C and Ctrl+D both cancel (Ctrl+D matches POSIX `read` EOF so a
        // user reaching for the standard shortcut doesn't type 'd' into a
        // password). Case-insensitive: with CapsLock on (or Ctrl+Shift+C) the
        // terminal may deliver Char('C')/Char('D').
        KeyCode::Char(c) if ctrl && matches!(c.to_ascii_lowercase(), 'c' | 'd') => {
            KeyEffect::Cancel
        }
        // Any other Ctrl+letter chord is dropped, never pushed as a literal —
        // otherwise Ctrl+U / Ctrl+W / Ctrl+L would silently corrupt the
        // captured secret with control bytes the user can't see.
        KeyCode::Char(_) if ctrl => KeyEffect::Ignore,
        KeyCode::Backspace => {
            if buf.pop().is_some() {
                KeyEffect::Echo("\x08 \x08")
            } else {
                KeyEffect::Ignore
            }
        }
        KeyCode::Char(c) => {
            buf.push(c);
            KeyEffect::Echo("*")
        }
        _ => KeyEffect::Ignore,
    }
}

/// Collapse a multi-line paste onto one line by dropping ONLY newlines /
/// carriage-returns — every other char (incl. tabs and other control bytes)
/// is kept verbatim so a pasted secret isn't silently mutated (masking would
/// hide the corruption). Pure, so the filter rule is unit-testable.
fn paste_filter(s: &str) -> String {
    s.chars().filter(|&c| c != '\n' && c != '\r').collect()
}

/// Layers `InteractiveIo` over a fallback resolver. Order: env hit → fallback
/// (preserves automation when a TTY happens to be attached); else TTY prompt;
/// else fallback again (non-TTY hands off without ever touching stdin).
pub struct InteractiveResolver {
    io: Box<dyn InteractiveIo>,
    fallback: Box<dyn InputResolver>,
}

impl InteractiveResolver {
    pub fn new<I, F>(io: I, fallback: F) -> Self
    where
        I: InteractiveIo + 'static,
        F: InputResolver + 'static,
    {
        Self {
            io: Box::new(io),
            fallback: Box::new(fallback),
        }
    }
}

impl InputResolver for InteractiveResolver {
    fn resolve(&self, key: &str, spec: &PromptSpec) -> ResolvedInput {
        // Env always wins — keeps the existing `VAR=value insmaller …`
        // automation working even when stdin happens to be a TTY.
        if let Some(v) = self.io.env(&spec.env_key) {
            return ResolvedInput::Value(v);
        }
        if !self.io.is_tty() {
            return self.fallback.resolve(key, spec);
        }
        match self.io.read_line(&spec.message, spec.secret) {
            Ok(InteractiveLine::Line(v)) => {
                if v.is_empty() {
                    if spec.required {
                        ResolvedInput::Fail(format!("input '{}' required", spec.env_key))
                    } else {
                        ResolvedInput::Skip
                    }
                } else {
                    ResolvedInput::Value(v)
                }
            }
            // Cancel on an optional input is treated as Skip (matches the
            // env-only path's behavior for an absent optional), so an Esc
            // doesn't abort an entire task over a discretionary prompt.
            Ok(InteractiveLine::Cancel) => {
                if spec.required {
                    ResolvedInput::Fail(format!("input '{}' cancelled", spec.env_key))
                } else {
                    ResolvedInput::Skip
                }
            }
            // A read race that surfaces a non-TTY (e.g. stdin was redirected
            // mid-flight) defers to the fallback rather than failing loudly.
            Ok(InteractiveLine::NoTty) => self.fallback.resolve(key, spec),
            Err(e) => ResolvedInput::Fail(format!("input '{}' read error: {e}", spec.env_key)),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use insmaller_core::EnvResolver;
    use std::collections::HashMap;
    use std::sync::Mutex;

    /// Test double: scripted answers + fake env + scripted tty flag.
    struct FakeIo {
        tty: bool,
        env: HashMap<String, String>,
        answers: Mutex<Vec<InteractiveLine>>,
    }

    impl FakeIo {
        fn new(tty: bool) -> Self {
            Self {
                tty,
                env: HashMap::new(),
                answers: Mutex::new(Vec::new()),
            }
        }
        fn with_env(mut self, k: &str, v: &str) -> Self {
            self.env.insert(k.into(), v.into());
            self
        }
        fn queue(self, line: InteractiveLine) -> Self {
            self.answers.lock().unwrap().insert(0, line);
            self
        }
    }

    impl InteractiveIo for FakeIo {
        fn is_tty(&self) -> bool {
            self.tty
        }
        fn env(&self, key: &str) -> Option<String> {
            self.env.get(key).cloned()
        }
        fn read_line(&self, _message: &str, _secret: bool) -> std::io::Result<InteractiveLine> {
            Ok(self
                .answers
                .lock()
                .unwrap()
                .pop()
                .unwrap_or(InteractiveLine::NoTty))
        }
    }

    fn spec(env_key: &str, required: bool, secret: bool) -> PromptSpec {
        PromptSpec {
            env_key: env_key.into(),
            message: format!("{env_key}:"),
            required,
            secret,
        }
    }

    /// Env key unlikely to be set in any host running the test suite.
    const UNSET: &str = "INSMALLER_INTERACTIVE_TEST_NEVER_SET_XYZ";

    #[test]
    fn env_wins_even_on_tty_and_skips_prompt() {
        let io = FakeIo::new(true).with_env("TOKEN", "abc");
        let r = InteractiveResolver::new(io, EnvResolver);
        let out = r.resolve("TOKEN", &spec("TOKEN", true, false));
        assert_eq!(out, ResolvedInput::Value("abc".into()));
    }

    #[test]
    fn no_tty_delegates_to_fallback() {
        // Fallback = EnvResolver; spec required + no env → Fail (env contract).
        let io = FakeIo::new(false);
        let r = InteractiveResolver::new(io, EnvResolver);
        let out = r.resolve("K", &spec(UNSET, true, false));
        assert!(matches!(out, ResolvedInput::Fail(_)));
    }

    #[test]
    fn no_tty_optional_missing_skips_via_fallback() {
        let io = FakeIo::new(false);
        let r = InteractiveResolver::new(io, EnvResolver);
        let out = r.resolve("K", &spec(UNSET, false, false));
        assert_eq!(out, ResolvedInput::Skip);
    }

    #[test]
    fn tty_prompt_reads_value() {
        let io = FakeIo::new(true).queue(InteractiveLine::Line("typed".into()));
        let r = InteractiveResolver::new(io, EnvResolver);
        let out = r.resolve("X", &spec("X", true, false));
        assert_eq!(out, ResolvedInput::Value("typed".into()));
    }

    #[test]
    fn tty_prompt_empty_required_fails_fast() {
        let io = FakeIo::new(true).queue(InteractiveLine::Line(String::new()));
        let r = InteractiveResolver::new(io, EnvResolver);
        let out = r.resolve("X", &spec("X", true, false));
        assert!(matches!(out, ResolvedInput::Fail(_)));
    }

    #[test]
    fn tty_prompt_cancel_required_reports_cancelled() {
        let io = FakeIo::new(true).queue(InteractiveLine::Cancel);
        let r = InteractiveResolver::new(io, EnvResolver);
        let out = r.resolve("X", &spec("X", true, false));
        match out {
            ResolvedInput::Fail(m) => assert!(m.contains("cancelled")),
            o => panic!("expected Fail(cancelled), got {o:?}"),
        }
    }

    #[test]
    fn tty_prompt_cancel_optional_skips() {
        // Esc/Ctrl+C on a `required=false` prompt becomes Skip — matches the
        // env-only path so cancelling a discretionary prompt doesn't abort
        // the task.
        let io = FakeIo::new(true).queue(InteractiveLine::Cancel);
        let r = InteractiveResolver::new(io, EnvResolver);
        let out = r.resolve("X", &spec("X", false, false));
        assert_eq!(out, ResolvedInput::Skip);
    }

    #[test]
    fn tty_prompt_optional_empty_skips() {
        let io = FakeIo::new(true).queue(InteractiveLine::Line(String::new()));
        let r = InteractiveResolver::new(io, EnvResolver);
        let out = r.resolve("X", &spec("X", false, false));
        assert_eq!(out, ResolvedInput::Skip);
    }

    // ── masked-reader line-editor rules (pure, no terminal needed) ──────────

    fn ch(c: char) -> KeyCode {
        KeyCode::Char(c)
    }

    #[test]
    fn masked_key_types_and_masks() {
        let mut buf = String::new();
        for c in "hunter2".chars() {
            assert!(matches!(masked_key(&mut buf, ch(c), false), KeyEffect::Echo("*")));
        }
        assert_eq!(buf, "hunter2");
    }

    #[test]
    fn masked_key_backspace_pops_and_erases_then_noops_on_empty() {
        let mut buf = "ab".to_string();
        assert!(matches!(
            masked_key(&mut buf, KeyCode::Backspace, false),
            KeyEffect::Echo("\x08 \x08")
        ));
        assert_eq!(buf, "a");
        masked_key(&mut buf, KeyCode::Backspace, false);
        assert_eq!(buf, "");
        // empty buffer → nothing to erase, no echo
        assert!(matches!(
            masked_key(&mut buf, KeyCode::Backspace, false),
            KeyEffect::Ignore
        ));
    }

    #[test]
    fn masked_key_ctrl_chords_drop_not_pushed() {
        let mut buf = "x".to_string();
        // Ctrl+U / Ctrl+W / Ctrl+L are ignored, never appended.
        for c in ['u', 'w', 'l', 'a'] {
            assert!(matches!(masked_key(&mut buf, ch(c), true), KeyEffect::Ignore));
        }
        assert_eq!(buf, "x", "no ctrl chord may corrupt the secret buffer");
    }

    #[test]
    fn masked_key_ctrl_c_and_d_cancel() {
        let mut buf = String::new();
        assert!(matches!(masked_key(&mut buf, ch('c'), true), KeyEffect::Cancel));
        assert!(matches!(masked_key(&mut buf, ch('d'), true), KeyEffect::Cancel));
        // Case-insensitive: CapsLock / Ctrl+Shift delivers uppercase.
        assert!(matches!(masked_key(&mut buf, ch('C'), true), KeyEffect::Cancel));
        assert!(matches!(masked_key(&mut buf, ch('D'), true), KeyEffect::Cancel));
    }

    #[test]
    fn masked_key_enter_submits_esc_cancels() {
        let mut buf = String::new();
        assert!(matches!(masked_key(&mut buf, KeyCode::Enter, false), KeyEffect::Submit));
        assert!(matches!(masked_key(&mut buf, KeyCode::Esc, false), KeyEffect::Cancel));
    }

    #[test]
    fn paste_filter_drops_only_line_breaks() {
        // tabs and other content survive; only \n and \r are stripped.
        assert_eq!(paste_filter("a\tb\r\nc\nd"), "a\tbcd");
        assert_eq!(paste_filter("no-breaks"), "no-breaks");
    }
}