Skip to main content

rusty_ts/
mode.rs

1//! Compatibility Mode resolution.
2//!
3//! Per `plan.md` AD-004 and `spec.md` FR-021..FR-023:
4//!
5//! Resolves the `CompatibilityMode` for an invocation once at startup at
6//! zero per-line cost. Precedence (highest first):
7//!
8//! 1. Explicit `--no-strict` / `--no-moreutils-compat` flag → `Default`
9//! 2. Explicit `--strict` / `--moreutils-compat` flag → `Strict`
10//! 3. `RUSTY_TS_STRICT` env var (`1`/`true`/`yes` = on; anything else = off)
11//! 4. argv[0] basename auto-detect: `ts` (or `ts.exe` stripped on Windows)
12//!    → `Strict`
13//! 5. Default
14//!
15//! Encoded as a single pure function `resolve(...)` so the precedence is
16//! testable in isolation (HINT-002 — the precedence ladder lives in exactly
17//! one place).
18
19/// The resolved compatibility-mode posture for the invocation.
20///
21/// Marked `#[non_exhaustive]` so future modes (e.g., explicit moreutils
22/// version pinning) can be added in minor versions.
23///
24/// # Example
25///
26/// ```
27/// use rusty_ts::{CompatibilityMode, TimestamperBuilder};
28///
29/// // Default mode — Rusty extensions active.
30/// let ts = TimestamperBuilder::new()
31///     .compat(CompatibilityMode::Default)
32///     .build()
33///     .unwrap();
34///
35/// // Strict mode — byte-identical moreutils behavior.
36/// let ts = TimestamperBuilder::new()
37///     .compat(CompatibilityMode::Strict)
38///     .build()
39///     .unwrap();
40/// # let _ = ts;
41/// ```
42#[non_exhaustive]
43#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum CompatibilityMode {
45    /// Rusty enhancements active: `-u`, `--tz`, `RUSTY_TS_FORMAT`, `-r`
46    /// subset, completions subcommand, Rusty-flavored `--help`.
47    #[default]
48    Default,
49    /// Byte-identical moreutils behavior: Rusty-only flags rejected,
50    /// `-r` expanded to full moreutils set, `RUSTY_TS_FORMAT` ignored,
51    /// `--help` / `--version` mirror moreutils layout.
52    Strict,
53}
54
55/// Explicit user choice from the CLI flag layer.
56///
57/// `None` if neither `--strict` nor `--no-strict` was supplied.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ExplicitChoice {
60    /// User passed `--strict` or `--moreutils-compat`.
61    Strict,
62    /// User passed `--no-strict` or `--no-moreutils-compat`.
63    Default,
64}
65
66/// Resolve the compatibility mode from the three inputs.
67///
68/// - `explicit_flag` — `Some(_)` if the user passed `--strict` / `--no-strict`.
69/// - `env_strict` — value of the `RUSTY_TS_STRICT` env var if present.
70/// - `argv0_basename` — argv[0] basename with any platform extension stripped.
71///
72/// Per FR-021 precedence, `--no-strict` beats `--strict`, which beats
73/// `RUSTY_TS_STRICT`, which beats argv[0] auto-detect.
74pub fn resolve(
75    explicit_flag: Option<ExplicitChoice>,
76    env_strict: Option<&str>,
77    argv0_basename: Option<&str>,
78) -> CompatibilityMode {
79    match explicit_flag {
80        Some(ExplicitChoice::Default) => return CompatibilityMode::Default,
81        Some(ExplicitChoice::Strict) => return CompatibilityMode::Strict,
82        None => {}
83    }
84
85    if let Some(value) = env_strict {
86        if env_var_is_truthy(value) {
87            return CompatibilityMode::Strict;
88        }
89    }
90
91    if let Some(name) = argv0_basename {
92        if name.eq_ignore_ascii_case("ts") {
93            return CompatibilityMode::Strict;
94        }
95    }
96
97    CompatibilityMode::Default
98}
99
100/// Parse an env-var value the way Unix-y tools usually do.
101///
102/// `1`, `true`, `yes`, `on` (any case) → enabled. Everything else → disabled.
103fn env_var_is_truthy(value: &str) -> bool {
104    matches!(
105        value.trim().to_ascii_lowercase().as_str(),
106        "1" | "true" | "yes" | "on"
107    )
108}
109
110/// Extract the argv[0] basename, stripping a `.exe` extension on Windows.
111///
112/// Returns `None` if argv is empty or the basename is unrecognizable. Used
113/// by FR-023 to auto-detect Strict mode when the binary is invoked as `ts`.
114pub fn argv0_basename(argv0: &std::ffi::OsStr) -> Option<String> {
115    use std::path::Path;
116    Path::new(argv0)
117        .file_stem()
118        .and_then(|s| s.to_str())
119        .map(|s| s.to_owned())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn explicit_no_strict_wins_over_everything() {
128        let mode = resolve(Some(ExplicitChoice::Default), Some("1"), Some("ts"));
129        assert_eq!(mode, CompatibilityMode::Default);
130    }
131
132    #[test]
133    fn explicit_strict_wins_over_env_and_argv() {
134        let mode = resolve(Some(ExplicitChoice::Strict), Some("0"), Some("rusty-ts"));
135        assert_eq!(mode, CompatibilityMode::Strict);
136    }
137
138    #[test]
139    fn env_truthy_enables_strict() {
140        for truthy in ["1", "true", "TRUE", "yes", "Yes", "on", "  1  "] {
141            assert_eq!(
142                resolve(None, Some(truthy), Some("rusty-ts")),
143                CompatibilityMode::Strict,
144                "env {truthy:?} should enable Strict",
145            );
146        }
147    }
148
149    #[test]
150    fn env_falsy_or_unset_falls_through() {
151        for falsy in ["0", "false", "no", "off", "", "  "] {
152            assert_eq!(
153                resolve(None, Some(falsy), Some("rusty-ts")),
154                CompatibilityMode::Default,
155                "env {falsy:?} should not enable Strict",
156            );
157        }
158        assert_eq!(
159            resolve(None, None, Some("rusty-ts")),
160            CompatibilityMode::Default,
161        );
162    }
163
164    #[test]
165    fn argv0_ts_enables_strict() {
166        assert_eq!(resolve(None, None, Some("ts")), CompatibilityMode::Strict);
167    }
168
169    #[test]
170    fn argv0_ts_case_insensitive() {
171        assert_eq!(resolve(None, None, Some("TS")), CompatibilityMode::Strict);
172        assert_eq!(resolve(None, None, Some("Ts")), CompatibilityMode::Strict);
173    }
174
175    #[test]
176    fn argv0_rusty_ts_stays_default() {
177        assert_eq!(
178            resolve(None, None, Some("rusty-ts")),
179            CompatibilityMode::Default,
180        );
181    }
182
183    #[test]
184    fn argv0_basename_strips_exe() {
185        use std::ffi::OsStr;
186        assert_eq!(argv0_basename(OsStr::new("ts.exe")).as_deref(), Some("ts"));
187        assert_eq!(argv0_basename(OsStr::new("ts")).as_deref(), Some("ts"));
188        assert_eq!(argv0_basename(OsStr::new("./ts")).as_deref(), Some("ts"),);
189    }
190
191    #[test]
192    fn argv0_basename_handles_path_components() {
193        use std::ffi::OsStr;
194        // On Unix this becomes "ts"; on Windows, paths like "C:\\bin\\ts.exe"
195        // are tested via the matching path-separator handling in std::path.
196        assert_eq!(
197            argv0_basename(OsStr::new("/usr/local/bin/ts")).as_deref(),
198            Some("ts"),
199        );
200    }
201
202    /// Exhaustive truth table covering the FR-021 precedence ladder.
203    #[test]
204    fn precedence_table() {
205        // (explicit, env, argv0_basename) -> expected
206        type Row = (
207            Option<ExplicitChoice>,
208            Option<&'static str>,
209            Option<&'static str>,
210            CompatibilityMode,
211        );
212        let cases: &[Row] = &[
213            // Default everywhere
214            (None, None, None, CompatibilityMode::Default),
215            (None, None, Some("rusty-ts"), CompatibilityMode::Default),
216            // argv[0] = ts
217            (None, None, Some("ts"), CompatibilityMode::Strict),
218            // env truthy
219            (None, Some("1"), Some("rusty-ts"), CompatibilityMode::Strict),
220            (None, Some("true"), None, CompatibilityMode::Strict),
221            // env falsy
222            (None, Some("0"), Some("ts"), CompatibilityMode::Strict), // argv wins over falsy env
223            (
224                None,
225                Some("0"),
226                Some("rusty-ts"),
227                CompatibilityMode::Default,
228            ),
229            // explicit --strict
230            (
231                Some(ExplicitChoice::Strict),
232                Some("0"),
233                Some("rusty-ts"),
234                CompatibilityMode::Strict,
235            ),
236            (
237                Some(ExplicitChoice::Strict),
238                None,
239                Some("ts"),
240                CompatibilityMode::Strict,
241            ),
242            // explicit --no-strict beats env and argv
243            (
244                Some(ExplicitChoice::Default),
245                Some("1"),
246                Some("ts"),
247                CompatibilityMode::Default,
248            ),
249            (
250                Some(ExplicitChoice::Default),
251                None,
252                Some("ts"),
253                CompatibilityMode::Default,
254            ),
255        ];
256
257        for (i, (explicit, env, argv, expected)) in cases.iter().enumerate() {
258            let actual = resolve(*explicit, *env, *argv);
259            assert_eq!(
260                actual, *expected,
261                "case {i}: explicit={explicit:?} env={env:?} argv={argv:?} expected {expected:?} got {actual:?}",
262            );
263        }
264    }
265}