Skip to main content

task_runs/
beholders.rs

1//! Beholder framework — observer-side parsers and rewriters for structured output.
2//!
3//! A *beholder* attaches to a `TaskRun` at spawn time and turns raw output
4//! chunks into structured `Event` rows. There are two modes:
5//!
6//! - **Rewriter:** mutates `argv` before the subprocess starts (e.g. cargo
7//!   gets `--message-format=json-render-diagnostics`).
8//! - **Parser:** reads human-formatted output and scrapes events out of it.
9//!
10//! # Integration point
11//!
12//! ```ignore
13//! let result = registry.attach(raw_cmd, &BeholderSelect::Auto, verbatim_output);
14//! // spawn the process with result.argv (may be rewritten)
15//! // for each chunk captured:
16//! if let Some(b) = result.beholder.as_mut() {
17//!     for event in b.parse_chunk(&chunk) {
18//!         store.append_event(run_id, ...);
19//!     }
20//! }
21//! // persist result.status on the run:
22//! store.update_beholder_status(run_id, &result.status)?;
23//! ```
24//!
25
26use crate::types::{BeholderStatus, ChunkRef, Event, EventSource, Level, OutputChunk};
27
28// ─── ToolVersionRange ─────────────────────────────────────────────────────────
29
30/// Declared support range for the *tool's* structured-output schema (not the
31/// beholder's own version). Used to surface `unknown_format` when drift is
32/// detected at parse time.
33///
34/// Strings are the underlying tool's version: e.g. `"1.38.0"` for cargo
35/// (when `--message-format=json-render-diagnostics` was stabilised).
36/// `None` means "no known lower/upper bound".
37#[derive(Debug, Clone)]
38pub struct ToolVersionRange {
39    pub min: Option<&'static str>,
40    pub max: Option<&'static str>,
41}
42
43// ─── BeholderMode ─────────────────────────────────────────────────────────────
44
45/// Whether a beholder modifies the invocation (rewriter) or reads human output
46/// (parser).
47#[derive(Clone)]
48pub enum BeholderMode {
49    /// Rewrites argv before spawn to enable structured output.
50    Rewriter {
51        /// Applied to argv in-place when the beholder attaches.
52        adjust_argv: fn(&mut Vec<String>),
53    },
54    /// Rewrites argv by appending a fixed set of args. Used by user-defined
55    /// drop-in beholders whose `add_args` are loaded from TOML at runtime.
56    DynamicRewriter {
57        /// Args to append when not already present in argv.
58        add_args: Vec<String>,
59    },
60    /// Reads the tool's human-formatted output without rewriting argv.
61    Parser,
62}
63
64// ─── BeholderSelect ───────────────────────────────────────────────────────────
65
66/// Caller-supplied attachment policy for `task.run`.
67#[derive(Debug, Clone, Default)]
68pub enum BeholderSelect {
69    /// Walk the registry in priority order; first matching beholder wins.
70    #[default]
71    Auto,
72    /// Pin to a specific beholder by name; bypass `matches` check.
73    ///
74    /// The beholder runs even if it would normally decline (e.g. explicit
75    /// `--message-format=human` for cargo). Recorded as `forced-against-flags`
76    /// on `beholder_status` in that case.
77    Force(String),
78    /// Bytes-only, regardless of registry contents.
79    None,
80}
81
82// ─── AttachResult ─────────────────────────────────────────────────────────────
83
84/// Returned by [`BeholderRegistry::attach`].
85pub struct AttachResult {
86    /// Attached per-run beholder instance, if any.
87    pub beholder: Option<Box<dyn Beholder>>,
88    /// Status string to store on `TaskRunMeta.beholder_status`.
89    pub status: BeholderStatus,
90    /// Argv to pass to the subprocess launcher.
91    ///
92    /// When a `Rewriter` beholder attaches, this reflects the adjusted argv.
93    /// Otherwise identical to the input tokens from `raw_cmd`.
94    pub argv: Vec<String>,
95}
96
97// ─── BeholderFactory ──────────────────────────────────────────────────────────
98
99/// Static descriptor + factory for a beholder type. Held in the registry.
100///
101/// `create()` produces a fresh, independent per-run instance each time.
102/// Implementors should be stateless — all per-run state belongs in the
103/// instance returned by `create`.
104pub trait BeholderFactory: Send + Sync {
105    fn name(&self) -> &'static str;
106    fn version(&self) -> &'static str;
107    /// Return `true` if this beholder handles the given invocation.
108    ///
109    /// Receives the *resolved* argv (wrappers stripped). May inspect argv to
110    /// detect user-set conflicting flags (e.g. `--message-format=human`) and
111    /// return `false` to decline gracefully.
112    fn matches(&self, resolved_argv: &[String]) -> bool;
113    fn mode(&self) -> BeholderMode;
114    /// Create a fresh per-run instance.
115    fn create(&self) -> Box<dyn Beholder>;
116    /// Declared support range for the underlying tool's structured output format.
117    ///
118    /// Informational: surfaced in `beholder_status` when format drift is detected
119    /// so agents can see which version window the beholder was built for.
120    /// `None` means the beholder makes no version claim.
121    fn tool_version_range(&self) -> Option<ToolVersionRange> { None }
122}
123
124// ─── Beholder ─────────────────────────────────────────────────────────────────
125
126/// Per-run stateful chunk processor.
127///
128/// The instance is private to a single `TaskRun`. `parse_chunk` is called for
129/// every captured chunk in arrival order and may produce zero or more structured
130/// events. Implementations must be incremental — buffering everything to EOF
131/// defeats the lossless-during-execution property.
132///
133/// For tools that dump a single JSON document at exit (e.g. ESLint, Biome),
134/// implement `parse_chunk` as a simple buffer accumulator and do all parsing
135/// in `on_done`, which the driver calls once the PTY reader exits.
136pub trait Beholder: Send {
137    fn name(&self) -> &'static str;
138    fn version(&self) -> &'static str;
139    fn mode(&self) -> BeholderMode;
140    /// Extract structured events from one output chunk.
141    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event>;
142    /// Called once after the last chunk has been delivered (PTY EOF).
143    ///
144    /// Use this for tools that write a single JSON document at exit rather than
145    /// streaming line-by-line. The driver passes the run id and a final
146    /// timestamp so the returned events can be fully populated.
147    ///
148    /// Default: returns no events (correct for streaming beholders).
149    fn on_done(&mut self, _run_id: &crate::types::TaskRunId, _offset_ms: u32) -> Vec<Event> {
150        Vec::new()
151    }
152    /// Returns a reason string if the beholder detected that the tool's output
153    /// format is unrecognized (schema drift). When `Some`, the driver detaches
154    /// this beholder and updates `beholder_status` to `unknown_format:name`.
155    ///
156    /// Default: `None` — format OK or not yet probed.
157    fn unknown_format_reason(&self) -> Option<&str> { None }
158}
159
160// ─── BeholderRegistry ────────────────────────────────────────────────────────
161
162/// Registry of available beholders, consulted at `task.run` time.
163///
164/// Entries are tried in insertion (priority) order. Earlier registrations have
165/// higher priority when multiple beholders' `matches` predicates would fire.
166pub struct BeholderRegistry {
167    entries: Vec<Box<dyn BeholderFactory>>,
168}
169
170impl BeholderRegistry {
171    pub fn new() -> Self {
172        Self { entries: Vec::new() }
173    }
174
175    /// Register a beholder factory. Earlier entries have higher priority.
176    pub fn register(&mut self, factory: Box<dyn BeholderFactory>) {
177        self.entries.push(factory);
178    }
179
180    /// Determine and attach a beholder for the given invocation.
181    ///
182    /// - Tokenizes `raw_cmd` and strips wrapper binaries (bunx, npx, pnpm, npm exec).
183    /// - `verbatim_output` signals that somebody reads this run's output as
184    ///   text — a human watching a terminal tile, or an attach client that
185    ///   promised its caller byte-identical passthrough. `Rewriter` beholders
186    ///   decline in `Auto` mode rather than change what that reader sees;
187    ///   `Force` still attaches but records `forced-against-verbatim`.
188    ///
189    ///   This flag used to be `tty_attached`, and the rename is the R739-B10
190    ///   fix rather than tidying. A PTY was never the thing that mattered: what
191    ///   matters is whether anything downstream renders the bytes. `yah build
192    ///   run` moved off a PTY onto pipes (R739-F6) and instantly started
193    ///   leaking `--message-format=json-render-diagnostics` output into an
194    ///   agent's stdout, because the only flag that could have stopped it was
195    ///   named after the mechanism instead of the requirement.
196    /// - Behaviour depends on `select`:
197    ///   - `Auto` — walk in priority order, first `matches` hit wins.
198    ///   - `Force(name)` — find by name; bypass `matches` (records
199    ///     `forced-against-flags` if the beholder would have declined, or
200    ///     `forced-against-verbatim` if the output is read verbatim and mode is
201    ///     Rewriter).
202    ///   - `None` — bytes-only; no beholder attached.
203    /// - If a `Rewriter` beholder attaches, its `adjust_argv` is applied to the
204    ///   returned `AttachResult.argv` and the diff is surfaced on `status`.
205    pub fn attach(
206        &self,
207        raw_cmd: &str,
208        select: &BeholderSelect,
209        verbatim_output: bool,
210    ) -> AttachResult {
211        let mut argv = resolve_argv(raw_cmd);
212
213        // A shell line is not an argv, and this registry can only reason about
214        // an argv. `resolve_argv` whitespace-splits whatever it is given, so a
215        // `Rewriter` beholder's `adjust_argv` appends its flag to the LAST
216        // token of the line — which for `cargo check … 2>&1 | tail -12` is
217        // `tail`'s argument list, not cargo's. Observed live: `tail:
218        // unrecognized option '--message-format=json-render-diagnostics'`,
219        // i.e. the beholder does not merely mis-instrument the run, it
220        // destroys the command.
221        //
222        // Splitting the line properly is not the fix. Even placed correctly,
223        // structured JSON on stdout would flow into the caller's own `| rg …`
224        // and give them nothing they asked for; and this crate deliberately
225        // carries no shell parser. So the honest answer for anything that is
226        // not one simple command is bytes-only, recorded as a decline rather
227        // than as `none:auto` so the reason is legible on the run.
228        if !is_single_simple_command(raw_cmd) && !matches!(select, BeholderSelect::None) {
229            return AttachResult {
230                beholder: None,
231                status: BeholderStatus::declined("auto", "compound-command"),
232                argv,
233            };
234        }
235
236        match select {
237            BeholderSelect::None => AttachResult {
238                beholder: None,
239                status: BeholderStatus::none_explicit(),
240                argv,
241            },
242
243            BeholderSelect::Force(name) => {
244                match self.entries.iter().find(|e| e.name() == name.as_str()) {
245                    None => AttachResult {
246                        beholder: None,
247                        status: BeholderStatus::none_auto(),
248                        argv,
249                    },
250                    Some(factory) => {
251                        let would_decline = !factory.matches(&argv);
252                        let is_rewriter = matches!(factory.mode(), BeholderMode::Rewriter { .. } | BeholderMode::DynamicRewriter { .. });
253                        let base_status = if would_decline {
254                            BeholderStatus::forced_against_flags(factory.name(), factory.version())
255                        } else if verbatim_output && is_rewriter {
256                            BeholderStatus::forced_against_verbatim(
257                                factory.name(),
258                                factory.version(),
259                            )
260                        } else {
261                            BeholderStatus::forced(factory.name(), factory.version())
262                        };
263                        let added = rewrite_if_rewriter(factory.mode(), &mut argv);
264                        let status = base_status.with_rewrite(added);
265                        AttachResult { beholder: Some(factory.create()), status, argv }
266                    }
267                }
268            }
269
270            BeholderSelect::Auto => {
271                for factory in &self.entries {
272                    if factory.matches(&argv) {
273                        let is_rewriter = matches!(factory.mode(), BeholderMode::Rewriter { .. } | BeholderMode::DynamicRewriter { .. });
274                        // Somebody reads these bytes: a Rewriter beholder would
275                        // change what they read, so it declines instead.
276                        if verbatim_output && is_rewriter {
277                            return AttachResult {
278                                beholder: None,
279                                status: BeholderStatus::declined(factory.name(), "verbatim-output"),
280                                argv,
281                            };
282                        }
283                        let added = rewrite_if_rewriter(factory.mode(), &mut argv);
284                        let status = BeholderStatus::attached(factory.name(), factory.version())
285                            .with_rewrite(added);
286                        return AttachResult {
287                            beholder: Some(factory.create()),
288                            status,
289                            argv,
290                        };
291                    }
292                }
293                AttachResult {
294                    beholder: None,
295                    status: BeholderStatus::none_auto(),
296                    argv,
297                }
298            }
299        }
300    }
301
302    pub fn len(&self) -> usize {
303        self.entries.len()
304    }
305
306    pub fn is_empty(&self) -> bool {
307        self.entries.is_empty()
308    }
309}
310
311impl Default for BeholderRegistry {
312    fn default() -> Self {
313        Self::new()
314    }
315}
316
317// ─── helpers ─────────────────────────────────────────────────────────────────
318
319/// Apply argv rewriting if `mode` is `Rewriter` or `DynamicRewriter`, and
320/// return the args that were added. Returns an empty `Vec` for `Parser` mode
321/// or when the rewriter added nothing.
322fn rewrite_if_rewriter(mode: BeholderMode, argv: &mut Vec<String>) -> Vec<String> {
323    match mode {
324        BeholderMode::Rewriter { adjust_argv } => {
325            let before = argv.clone();
326            adjust_argv(argv);
327            argv.iter().filter(|a| !before.contains(a)).cloned().collect()
328        }
329        BeholderMode::DynamicRewriter { add_args } => {
330            let mut added = Vec::new();
331            for arg in add_args {
332                if !argv.contains(&arg) {
333                    argv.push(arg.clone());
334                    added.push(arg);
335                }
336            }
337            added
338        }
339        BeholderMode::Parser => Vec::new(),
340    }
341}
342
343// ─── argv resolution ─────────────────────────────────────────────────────────
344
345/// Tokenize `raw_cmd` and strip common wrapper binaries so beholders see the
346/// bare tool name as `argv[0]`.
347///
348/// Wrappers stripped:
349/// - `bunx` / `npx` — drop the wrapper token.
350/// - `pnpm` / `npm exec` — drop the wrapper and optional `exec` subcommand.
351pub fn resolve_argv(raw_cmd: &str) -> Vec<String> {
352    let mut argv: Vec<String> = tokenize(raw_cmd);
353    loop {
354        match argv.first().map(String::as_str) {
355            Some("bunx") | Some("npx") => {
356                argv.remove(0);
357            }
358            Some("pnpm") | Some("npm") => {
359                argv.remove(0);
360                if argv.first().map(String::as_str) == Some("exec") {
361                    argv.remove(0);
362                }
363                break;
364            }
365            _ => break,
366        }
367    }
368    argv
369}
370
371/// Split on ASCII whitespace. Sufficient for well-formed command strings;
372/// does not handle shell quoting.
373fn tokenize(s: &str) -> Vec<String> {
374    s.split_ascii_whitespace().map(str::to_owned).collect()
375}
376
377/// Is `raw_cmd` one simple command, i.e. is treating it as an argv sound?
378///
379/// Deliberately a character scan and deliberately over-strict: this crate has
380/// no shell parser and should not grow one to answer a safety question. Every
381/// false positive costs one run its structured events; every false negative
382/// costs that run its *command*, because a `Rewriter` beholder will splice a
383/// flag into whatever program happens to be last on the line.
384///
385/// `$` is in the set for command substitution (`$(…)`) rather than for plain
386/// variable expansion, which would be harmless — but distinguishing the two
387/// needs the parser this function exists to avoid.
388fn is_single_simple_command(raw_cmd: &str) -> bool {
389    !raw_cmd
390        .chars()
391        .any(|c| matches!(c, '|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' | '$' | '\n'))
392}
393
394// ─── Cargo beholder ───────────────────────────────────────────────────────────
395
396/// Cargo subcommands that produce rustc diagnostic output.
397const CARGO_DIAG_SUBCOMMANDS: &[&str] = &[
398    "check", "build", "test", "clippy", "run", "fix", "doc", "bench", "publish",
399];
400
401/// Factory for the bundled cargo beholder (Tier 1.5, Rewriter mode).
402///
403/// Matches `cargo <sub>` invocations for diagnostic-producing subcommands and
404/// rewrites argv to add `--message-format=json-render-diagnostics`, which
405/// makes cargo emit one JSON object per line rather than human-formatted text.
406///
407/// Declines when the user already specified `--message-format` (respects
408/// explicit intent), and — since the rewrite replaces cargo's own human output
409/// with JSON — whenever the run is spawned with `verbatim_output`, i.e. when
410/// something downstream is going to render those bytes (R070-T5, R739-B10).
411pub struct CargoBeholderFactory;
412
413impl BeholderFactory for CargoBeholderFactory {
414    fn name(&self) -> &'static str { "cargo" }
415    fn version(&self) -> &'static str { "1.38" }
416
417    fn matches(&self, argv: &[String]) -> bool {
418        if argv.first().map(String::as_str) != Some("cargo") {
419            return false;
420        }
421        let has_diag_sub = argv.iter().skip(1)
422            .any(|a| CARGO_DIAG_SUBCOMMANDS.contains(&a.as_str()));
423        if !has_diag_sub {
424            return false;
425        }
426        // Decline when the user explicitly chose a message format.
427        !argv.iter().any(|a| a.starts_with("--message-format"))
428    }
429
430    fn mode(&self) -> BeholderMode {
431        BeholderMode::Rewriter {
432            adjust_argv: |argv| {
433                argv.push("--message-format=json-render-diagnostics".to_owned());
434            },
435        }
436    }
437
438    fn create(&self) -> Box<dyn Beholder> {
439        Box::new(CargoBeholder::default())
440    }
441
442    fn tool_version_range(&self) -> Option<ToolVersionRange> {
443        // --message-format=json-render-diagnostics was stabilised in cargo 1.38.0.
444        // No upper bound — the format has been additive since stabilisation.
445        Some(ToolVersionRange { min: Some("1.38.0"), max: None })
446    }
447}
448
449/// After this many JSON-shaped lines fail to match the `CargoLine` schema
450/// (missing `reason` field, etc.) without a single recognizable line, the
451/// beholder declares `unknown_format` and stops emitting events.
452const FORMAT_PROBE_LIMIT: u8 = 5;
453
454/// Per-run cargo JSON parser.
455///
456/// Buffers incoming bytes and extracts complete newline-delimited JSON records
457/// as they arrive. Non-JSON lines (blank lines, unexpected text) are silently
458/// skipped so a stray progress line never stalls the stream.
459///
460/// If the first [`FORMAT_PROBE_LIMIT`] JSON-shaped lines all fail to match the
461/// expected cargo schema (missing `reason` field), `unknown_format_reason`
462/// returns a non-`None` value and `parse_chunk` stops emitting events.
463pub struct CargoBeholder {
464    buf: Vec<u8>,
465    /// JSON-object lines seen that don't match the `CargoLine` schema.
466    /// Only counted before a recognizable cargo line has been seen.
467    json_lines_unrecognized: u8,
468    /// Set once a line successfully parses as `CargoLine` (has `reason` field).
469    /// After this point unrecognized lines are silently dropped (forward compat).
470    recognized_line_seen: bool,
471    /// Set when `json_lines_unrecognized >= FORMAT_PROBE_LIMIT` and no cargo
472    /// line has been seen yet — indicates the output format is unrecognized.
473    unknown_format: Option<String>,
474}
475
476impl Default for CargoBeholder {
477    fn default() -> Self {
478        Self {
479            buf: Vec::new(),
480            json_lines_unrecognized: 0,
481            recognized_line_seen: false,
482            unknown_format: None,
483        }
484    }
485}
486
487impl Beholder for CargoBeholder {
488    fn name(&self) -> &'static str { "cargo" }
489    fn version(&self) -> &'static str { "1.38" }
490    fn mode(&self) -> BeholderMode {
491        BeholderMode::Rewriter {
492            adjust_argv: |argv| {
493                argv.push("--message-format=json-render-diagnostics".to_owned());
494            },
495        }
496    }
497
498    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
499        if self.unknown_format.is_some() {
500            return Vec::new();
501        }
502        self.buf.extend_from_slice(&chunk.bytes);
503        let mut events = Vec::new();
504        while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
505            let line: Vec<u8> = self.buf.drain(..=nl).collect();
506            // Strip trailing \n (and optional \r for Windows cargo output).
507            let line = line.trim_ascii_end();
508            if line.is_empty() || line[0] != b'{' {
509                continue;
510            }
511            let Ok(s) = std::str::from_utf8(line) else { continue };
512
513            // Try to parse as a CargoLine (requires `reason: String`).
514            match serde_json::from_str::<CargoLine>(s) {
515                Ok(cargo_line) => {
516                    self.recognized_line_seen = true;
517                    if let Some(ev) = parse_cargo_line_inner(&cargo_line, chunk) {
518                        events.push(ev);
519                    }
520                }
521                Err(_) => {
522                    // JSON object but doesn't fit CargoLine schema.
523                    // Only counts toward the probe threshold before we've seen
524                    // a recognized line; after that it's just forward compat.
525                    if !self.recognized_line_seen {
526                        self.json_lines_unrecognized =
527                            self.json_lines_unrecognized.saturating_add(1);
528                        if self.json_lines_unrecognized >= FORMAT_PROBE_LIMIT {
529                            self.unknown_format = Some(format!(
530                                "no recognizable cargo JSON lines in first {} JSON-object lines",
531                                FORMAT_PROBE_LIMIT
532                            ));
533                            return Vec::new();
534                        }
535                    }
536                }
537            }
538        }
539        events
540    }
541
542    fn unknown_format_reason(&self) -> Option<&str> {
543        self.unknown_format.as_deref()
544    }
545}
546
547/// Construct a [`BeholderRegistry`] pre-loaded with all bundled beholders.
548pub fn default_registry() -> BeholderRegistry {
549    let mut r = BeholderRegistry::new();
550    r.register(Box::new(CargoBeholderFactory));
551    r.register(Box::new(TscBeholderFactory));
552    r.register(Box::new(EslintBeholderFactory));
553    r.register(Box::new(BiomeBeholderFactory));
554    r.register(Box::new(VitestBeholderFactory));
555    r.register(Box::new(JestBeholderFactory));
556    r.register(Box::new(BunTestBeholderFactory));
557    r.register(Box::new(PytestBeholderFactory));
558    r.register(Box::new(ViteBuildBeholderFactory));
559    r
560}
561
562/// Construct a [`BeholderRegistry`] with bundled beholders plus any user-defined
563/// beholders loaded from `user_dir` (typically `~/.yah/beholders/`).
564///
565/// User beholders are appended after bundled ones, so bundled beholders have
566/// higher priority when both would match the same command. Malformed or
567/// unreadable TOML files in `user_dir` are skipped silently.
568pub fn registry_with_user_beholders(user_dir: Option<&std::path::Path>) -> BeholderRegistry {
569    let mut r = default_registry();
570    if let Some(dir) = user_dir {
571        for factory in crate::user_beholders::load_user_beholders(dir) {
572            r.register(factory);
573        }
574    }
575    r
576}
577
578// ─── tsc beholder ─────────────────────────────────────────────────────────────
579
580/// Factory for the bundled tsc beholder (Tier 1.5, Rewriter + Parser).
581///
582/// Matches `tsc` invocations (bare or via wrapper: `bunx tsc`, `pnpm tsc`,
583/// `npx tsc`) and rewrites argv to add `--pretty=false` when not already set,
584/// enabling the machine-parseable diagnostic format:
585///
586/// ```text
587/// src/foo.ts(10,5): error TS2345: Argument of type 'string' is not assignable…
588/// Found 1 error.
589/// ```
590///
591/// Declines when:
592/// - `--pretty` or `--pretty=true` is already set (user wants colored output).
593/// - `--version` / `-v` / `--init` are present (non-diagnostic invocations).
594/// - `--pretty=false` is already present (rewriter is a no-op; beholder still
595///   attaches in Parser mode so diagnostics are captured — argv unchanged, no
596///   `rewrite_added` entry on status).
597pub struct TscBeholderFactory;
598
599impl BeholderFactory for TscBeholderFactory {
600    fn name(&self) -> &'static str { "tsc" }
601    fn version(&self) -> &'static str { "3.0" }
602
603    fn matches(&self, argv: &[String]) -> bool {
604        if argv.first().map(String::as_str) != Some("tsc") {
605            return false;
606        }
607        // Decline for non-diagnostic invocations.
608        if argv.iter().any(|a| matches!(a.as_str(), "--version" | "-v" | "--init")) {
609            return false;
610        }
611        // Decline if the user explicitly requested colored (pretty) output.
612        // `--pretty` alone means true; `--pretty=true` is explicit. We do NOT
613        // decline for `--pretty=false` — the rewriter becomes a no-op but we
614        // still want to parse the output.
615        !argv.iter().any(|a| a == "--pretty" || a == "--pretty=true")
616    }
617
618    fn mode(&self) -> BeholderMode {
619        BeholderMode::Rewriter {
620            adjust_argv: |argv| {
621                // Only inject if the user hasn't already set --pretty=false.
622                if !argv.iter().any(|a| a == "--pretty=false") {
623                    argv.push("--pretty=false".to_owned());
624                }
625            },
626        }
627    }
628
629    fn create(&self) -> Box<dyn Beholder> {
630        Box::new(TscBeholder::default())
631    }
632
633    fn tool_version_range(&self) -> Option<ToolVersionRange> {
634        // --pretty=false has been available since tsc 1.x; 3.0.0 is a
635        // conservative lower bound for the stable output format.
636        Some(ToolVersionRange { min: Some("3.0.0"), max: None })
637    }
638}
639
640/// Per-run tsc parser.
641///
642/// Buffers incoming bytes and extracts complete lines. Diagnostic lines in the
643/// `--pretty=false` format are turned into structured events; watch-mode
644/// timestamp headers and blank lines are silently skipped. Summary lines
645/// ("Found N errors.") produce an Info event so the agent can see the final
646/// outcome without polling run status.
647pub struct TscBeholder {
648    buf: Vec<u8>,
649}
650
651impl Default for TscBeholder {
652    fn default() -> Self {
653        Self { buf: Vec::new() }
654    }
655}
656
657impl Beholder for TscBeholder {
658    fn name(&self) -> &'static str { "tsc" }
659    fn version(&self) -> &'static str { "3.0" }
660    fn mode(&self) -> BeholderMode {
661        BeholderMode::Rewriter {
662            adjust_argv: |argv| {
663                if !argv.iter().any(|a| a == "--pretty=false") {
664                    argv.push("--pretty=false".to_owned());
665                }
666            },
667        }
668    }
669
670    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
671        self.buf.extend_from_slice(&chunk.bytes);
672        let mut events = Vec::new();
673        while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
674            let raw: Vec<u8> = self.buf.drain(..=nl).collect();
675            let Ok(line) = std::str::from_utf8(raw.trim_ascii_end()) else { continue };
676            if line.is_empty() { continue }
677            // Skip watch-mode timestamp headers: "[12:00:00 AM] …"
678            if line.starts_with('[') { continue }
679
680            let source = EventSource::Beholder {
681                name: "tsc".to_owned(),
682                version: "3.0".to_owned(),
683            };
684
685            if let Some(diag) = parse_tsc_diagnostic(line) {
686                let mut fields = serde_json::json!({});
687                fields["error"] = serde_json::json!({ "code": diag.code });
688                fields["file"] = serde_json::json!({
689                    "path": diag.file,
690                    "line": diag.line,
691                    "col":  diag.col,
692                });
693                events.push(Event {
694                    run_id: chunk.run_id.clone(),
695                    seq: 0,
696                    offset_ms: chunk.offset_ms,
697                    level: diag.level,
698                    target: "tsc".to_owned(),
699                    msg: diag.msg,
700                    fields,
701                    anchor: Some(ChunkRef { seq: chunk.seq }),
702                    source,
703                });
704            } else if let Some(summary) = parse_tsc_summary(line) {
705                events.push(Event {
706                    run_id: chunk.run_id.clone(),
707                    seq: 0,
708                    offset_ms: chunk.offset_ms,
709                    level: if summary.errors == 0 { Level::Info } else { Level::Error },
710                    target: "tsc".to_owned(),
711                    msg: line.to_owned(),
712                    fields: serde_json::json!({ "build": { "errors": summary.errors } }),
713                    anchor: Some(ChunkRef { seq: chunk.seq }),
714                    source,
715                });
716            }
717        }
718        events
719    }
720}
721
722// ─── tsc parsing helpers ──────────────────────────────────────────────────────
723
724struct TscDiagnostic {
725    file: String,
726    line: u32,
727    col: u32,
728    level: Level,
729    code: String,
730    msg: String,
731}
732
733struct TscSummary {
734    errors: u32,
735}
736
737/// Parse a single `--pretty=false` diagnostic line of the form:
738/// `<file>(<line>,<col>): <level> TS<code>: <message>`
739fn parse_tsc_diagnostic(line: &str) -> Option<TscDiagnostic> {
740    const MARKERS: &[(&str, Level)] = &[
741        ("): error TS", Level::Error),
742        ("): warning TS", Level::Warn),
743        ("): message TS", Level::Info),
744    ];
745
746    for (marker, level) in MARKERS {
747        let Some(marker_pos) = line.find(marker) else { continue };
748
749        // Everything before ")" is "file(line,col".
750        let file_pos_str = &line[..marker_pos];
751        let Some((file, ln, col)) = parse_file_pos(file_pos_str) else { continue };
752
753        // After "): error TS" (etc.) starts the numeric code.
754        let after = &line[marker_pos + marker.len()..];
755        let code_len = after.chars().take_while(|c| c.is_ascii_digit()).count();
756        if code_len == 0 { continue; }
757        let code = format!("TS{}", &after[..code_len]);
758
759        let rest = &after[code_len..];
760        if !rest.starts_with(": ") { continue; }
761        let msg = rest[2..].to_owned();
762
763        return Some(TscDiagnostic { file, line: ln, col, level: *level, code, msg });
764    }
765    None
766}
767
768/// Extract `(file, line, col)` from the `file(line,col` part of a tsc diagnostic.
769///
770/// `s` is the substring before the closing `)`, e.g. `"src/foo.ts(10,5"`.
771fn parse_file_pos(s: &str) -> Option<(String, u32, u32)> {
772    let comma = s.rfind(',')?;
773    let col: u32 = s[comma + 1..].parse().ok()?;
774
775    let before_comma = &s[..comma];
776    let open_paren = before_comma.rfind('(')?;
777    let ln: u32 = before_comma[open_paren + 1..].parse().ok()?;
778
779    let file = s[..open_paren].to_owned();
780    if file.is_empty() { return None; }
781
782    Some((file, ln, col))
783}
784
785/// Parse `"Found N errors."` / `"Found N errors in M files."` summary lines.
786fn parse_tsc_summary(line: &str) -> Option<TscSummary> {
787    let rest = line.strip_prefix("Found ")?;
788    // Extract the leading digit sequence (error count).
789    let count_len = rest.chars().take_while(|c| c.is_ascii_digit()).count();
790    if count_len == 0 { return None; }
791    let errors: u32 = rest[..count_len].parse().ok()?;
792    // Remainder must start with " error" to distinguish from other "Found …" lines.
793    if !rest[count_len..].starts_with(" error") { return None; }
794    Some(TscSummary { errors })
795}
796
797// ─── Cargo JSON parsing ───────────────────────────────────────────────────────
798
799#[derive(serde::Deserialize)]
800struct CargoLine {
801    reason: String,
802    message: Option<CargoMessage>,
803    success: Option<bool>,
804}
805
806#[derive(serde::Deserialize)]
807struct CargoMessage {
808    message: String,
809    level: String,
810    code: Option<CargoCode>,
811    spans: Vec<CargoSpan>,
812}
813
814#[derive(serde::Deserialize)]
815struct CargoCode {
816    code: String,
817}
818
819#[derive(serde::Deserialize)]
820struct CargoSpan {
821    file_name: String,
822    is_primary: bool,
823    line_start: u32,
824    column_start: u32,
825}
826
827/// Convert an already-parsed `CargoLine` into a structured `Event`, or `None`
828/// for reasons we intentionally skip (e.g. `compiler-artifact`).
829fn parse_cargo_line_inner(line: &CargoLine, chunk: &OutputChunk) -> Option<Event> {
830    let source = EventSource::Beholder {
831        name: "cargo".to_owned(),
832        version: "1.38".to_owned(),
833    };
834
835    match line.reason.as_str() {
836        "compiler-message" => {
837            let msg = line.message.as_ref()?;
838            let level = cargo_level(&msg.level);
839            let primary = msg.spans.iter().find(|s| s.is_primary);
840
841            let mut fields = serde_json::json!({});
842            if let Some(code) = &msg.code {
843                fields["error"] = serde_json::json!({ "code": code.code });
844            }
845            if let Some(span) = primary {
846                fields["file"] = serde_json::json!({
847                    "path": span.file_name,
848                    "line": span.line_start,
849                    "col":  span.column_start,
850                });
851            }
852
853            Some(Event {
854                run_id: chunk.run_id.clone(),
855                seq: 0, // assigned by store on insert
856                offset_ms: chunk.offset_ms,
857                level,
858                target: "cargo::rustc".to_owned(),
859                msg: msg.message.clone(),
860                fields,
861                anchor: Some(ChunkRef { seq: chunk.seq }),
862                source,
863            })
864        }
865        "build-finished" => {
866            let ok = line.success.unwrap_or(false);
867            Some(Event {
868                run_id: chunk.run_id.clone(),
869                seq: 0,
870                offset_ms: chunk.offset_ms,
871                level: if ok { Level::Info } else { Level::Error },
872                target: "cargo".to_owned(),
873                msg: if ok { "build finished".to_owned() } else { "build failed".to_owned() },
874                fields: serde_json::json!({ "build": { "success": ok } }),
875                anchor: Some(ChunkRef { seq: chunk.seq }),
876                source,
877            })
878        }
879        _ => None,
880    }
881}
882
883fn cargo_level(s: &str) -> Level {
884    match s {
885        "error" | "failure-note" => Level::Error,
886        "warning" => Level::Warn,
887        "note" | "help" => Level::Info,
888        _ => Level::Debug,
889    }
890}
891
892// ─── ESLint beholder ─────────────────────────────────────────────────────────
893
894/// Factory for the bundled ESLint beholder (Tier 1.5, Rewriter mode).
895///
896/// Matches `eslint` invocations and rewrites argv to add `--format=json`, which
897/// makes ESLint emit a single JSON array at exit rather than human-formatted
898/// text. Parsing happens in `on_done` because ESLint writes the entire document
899/// at process exit, not line-by-line.
900///
901/// Declines when:
902/// - `--version`, `--env-info`, or `--print-config` are present (non-lint).
903/// - `--format=<value>` is already set to something other than `json` (respects
904///   explicit user intent; if it's already `json`, the rewriter is a no-op and
905///   the beholder still attaches as a parser).
906pub struct EslintBeholderFactory;
907
908impl BeholderFactory for EslintBeholderFactory {
909    fn name(&self) -> &'static str { "eslint" }
910    fn version(&self) -> &'static str { "8.0" }
911
912    fn matches(&self, argv: &[String]) -> bool {
913        if argv.first().map(String::as_str) != Some("eslint") {
914            return false;
915        }
916        // Non-lint invocations.
917        if argv.iter().any(|a| matches!(a.as_str(), "--version" | "--env-info" | "--print-config")) {
918            return false;
919        }
920        // Decline if the user explicitly chose a non-json formatter.
921        // `--format=json` is fine — rewriter is a no-op; beholder still parses.
922        // `-f json` / `-f compact` etc. are also handled.
923        let format_arg = argv.windows(2)
924            .find(|w| w[0] == "-f" || w[0] == "--format")
925            .map(|w| w[1].as_str());
926        let format_eq = argv.iter()
927            .find(|a| a.starts_with("--format="))
928            .map(|a| a.trim_start_matches("--format="));
929        let explicit_format = format_arg.or(format_eq);
930        matches!(explicit_format, None | Some("json"))
931    }
932
933    fn mode(&self) -> BeholderMode {
934        BeholderMode::Rewriter {
935            adjust_argv: |argv| {
936                if !argv.iter().any(|a| a == "--format=json") {
937                    argv.push("--format=json".to_owned());
938                }
939            },
940        }
941    }
942
943    fn create(&self) -> Box<dyn Beholder> {
944        Box::new(EslintBeholder::default())
945    }
946
947    fn tool_version_range(&self) -> Option<ToolVersionRange> {
948        // --format=json has been available since ESLint 1.x; 8.0 is the
949        // ESLint version range this beholder was built and tested against.
950        Some(ToolVersionRange { min: Some("8.0.0"), max: None })
951    }
952}
953
954/// Per-run ESLint JSON parser. Buffers all output; parses in `on_done`.
955///
956/// ESLint writes the entire JSON array to stdout at process exit. There is no
957/// streaming line-by-line format in the `json` formatter, so incremental
958/// parsing is not possible. The beholder accumulates raw bytes and flushes
959/// structured events once the PTY closes.
960pub struct EslintBeholder {
961    buf: Vec<u8>,
962    unknown_format: Option<String>,
963}
964
965impl Default for EslintBeholder {
966    fn default() -> Self {
967        Self { buf: Vec::new(), unknown_format: None }
968    }
969}
970
971impl Beholder for EslintBeholder {
972    fn name(&self) -> &'static str { "eslint" }
973    fn version(&self) -> &'static str { "8.0" }
974    fn mode(&self) -> BeholderMode {
975        BeholderMode::Rewriter {
976            adjust_argv: |argv| {
977                if !argv.iter().any(|a| a == "--format=json") {
978                    argv.push("--format=json".to_owned());
979                }
980            },
981        }
982    }
983
984    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
985        self.buf.extend_from_slice(&chunk.bytes);
986        Vec::new()
987    }
988
989    fn on_done(&mut self, run_id: &crate::types::TaskRunId, offset_ms: u32) -> Vec<Event> {
990        if self.buf.is_empty() {
991            return Vec::new();
992        }
993        let Ok(s) = std::str::from_utf8(&self.buf) else {
994            self.unknown_format = Some("non-UTF-8 output".to_owned());
995            return Vec::new();
996        };
997        // ESLint JSON output is an array; strip any leading/trailing ANSI escapes
998        // or shell prompt noise that a PTY might inject before/after the document.
999        let s = s.trim();
1000        match serde_json::from_str::<Vec<EslintFile>>(s) {
1001            Ok(files) => eslint_to_events(files, run_id, offset_ms),
1002            Err(e) => {
1003                self.unknown_format = Some(format!("failed to parse ESLint JSON: {e}"));
1004                Vec::new()
1005            }
1006        }
1007    }
1008
1009    fn unknown_format_reason(&self) -> Option<&str> {
1010        self.unknown_format.as_deref()
1011    }
1012}
1013
1014// ─── ESLint JSON structs ──────────────────────────────────────────────────────
1015
1016#[derive(serde::Deserialize)]
1017struct EslintFile {
1018    #[serde(rename = "filePath")]
1019    file_path: String,
1020    messages: Vec<EslintMessage>,
1021}
1022
1023#[derive(serde::Deserialize)]
1024struct EslintMessage {
1025    #[serde(rename = "ruleId")]
1026    rule_id: Option<String>,
1027    severity: u8,
1028    message: String,
1029    line: Option<u32>,
1030    column: Option<u32>,
1031}
1032
1033fn eslint_to_events(
1034    files: Vec<EslintFile>,
1035    run_id: &crate::types::TaskRunId,
1036    offset_ms: u32,
1037) -> Vec<Event> {
1038    let mut events = Vec::new();
1039    for file in files {
1040        for msg in file.messages {
1041            let level = match msg.severity {
1042                2 => Level::Error,
1043                1 => Level::Warn,
1044                _ => Level::Info,
1045            };
1046            let mut fields = serde_json::json!({});
1047            if let Some(ref rule) = msg.rule_id {
1048                fields["error"] = serde_json::json!({ "code": rule });
1049            }
1050            let mut file_fields = serde_json::json!({ "path": file.file_path });
1051            if let Some(l) = msg.line { file_fields["line"] = serde_json::json!(l); }
1052            if let Some(c) = msg.column { file_fields["col"] = serde_json::json!(c); }
1053            fields["file"] = file_fields;
1054
1055            events.push(Event {
1056                run_id: run_id.clone(),
1057                seq: 0,
1058                offset_ms,
1059                level,
1060                target: "eslint".to_owned(),
1061                msg: msg.message,
1062                fields,
1063                anchor: None,
1064                source: EventSource::Beholder {
1065                    name: "eslint".to_owned(),
1066                    version: "8.0".to_owned(),
1067                },
1068            });
1069        }
1070    }
1071    events
1072}
1073
1074// ─── Biome beholder ──────────────────────────────────────────────────────────
1075
1076/// Subcommands that produce lint diagnostics.
1077const BIOME_LINT_SUBCOMMANDS: &[&str] = &["check", "lint", "ci"];
1078
1079/// Factory for the bundled Biome beholder (Tier 1.5, Rewriter mode).
1080///
1081/// Matches `biome check/lint/ci` invocations and rewrites argv to add
1082/// `--reporter=json`, which makes Biome emit a single JSON object at exit.
1083/// Parsing happens in `on_done`.
1084///
1085/// Declines when:
1086/// - Subcommand is not one of `check`, `lint`, `ci`.
1087/// - `--reporter=<value>` is already set to something other than `json`.
1088/// - `--version` is present.
1089pub struct BiomeBeholderFactory;
1090
1091impl BeholderFactory for BiomeBeholderFactory {
1092    fn name(&self) -> &'static str { "biome" }
1093    fn version(&self) -> &'static str { "1.0" }
1094
1095    fn matches(&self, argv: &[String]) -> bool {
1096        if argv.first().map(String::as_str) != Some("biome") {
1097            return false;
1098        }
1099        if argv.iter().any(|a| a == "--version") {
1100            return false;
1101        }
1102        // Must have a lint-producing subcommand.
1103        let has_lint_sub = argv.iter().skip(1)
1104            .any(|a| BIOME_LINT_SUBCOMMANDS.contains(&a.as_str()));
1105        if !has_lint_sub {
1106            return false;
1107        }
1108        // Decline if the user explicitly chose a non-json reporter.
1109        let reporter = argv.iter()
1110            .find(|a| a.starts_with("--reporter="))
1111            .map(|a| a.trim_start_matches("--reporter="));
1112        matches!(reporter, None | Some("json"))
1113    }
1114
1115    fn mode(&self) -> BeholderMode {
1116        BeholderMode::Rewriter {
1117            adjust_argv: |argv| {
1118                if !argv.iter().any(|a| a == "--reporter=json") {
1119                    argv.push("--reporter=json".to_owned());
1120                }
1121            },
1122        }
1123    }
1124
1125    fn create(&self) -> Box<dyn Beholder> {
1126        Box::new(BiomeBeholder::default())
1127    }
1128
1129    fn tool_version_range(&self) -> Option<ToolVersionRange> {
1130        // --reporter=json was available from Biome 1.0.
1131        Some(ToolVersionRange { min: Some("1.0.0"), max: None })
1132    }
1133}
1134
1135/// Per-run Biome JSON parser. Buffers all output; parses in `on_done`.
1136///
1137/// Biome writes a single JSON object to stdout at process exit. Like ESLint,
1138/// the entire document appears at once, so incremental parsing is not possible.
1139pub struct BiomeBeholder {
1140    buf: Vec<u8>,
1141    unknown_format: Option<String>,
1142}
1143
1144impl Default for BiomeBeholder {
1145    fn default() -> Self {
1146        Self { buf: Vec::new(), unknown_format: None }
1147    }
1148}
1149
1150impl Beholder for BiomeBeholder {
1151    fn name(&self) -> &'static str { "biome" }
1152    fn version(&self) -> &'static str { "1.0" }
1153    fn mode(&self) -> BeholderMode {
1154        BeholderMode::Rewriter {
1155            adjust_argv: |argv| {
1156                if !argv.iter().any(|a| a == "--reporter=json") {
1157                    argv.push("--reporter=json".to_owned());
1158                }
1159            },
1160        }
1161    }
1162
1163    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1164        self.buf.extend_from_slice(&chunk.bytes);
1165        Vec::new()
1166    }
1167
1168    fn on_done(&mut self, run_id: &crate::types::TaskRunId, offset_ms: u32) -> Vec<Event> {
1169        if self.buf.is_empty() {
1170            return Vec::new();
1171        }
1172        let Ok(s) = std::str::from_utf8(&self.buf) else {
1173            self.unknown_format = Some("non-UTF-8 output".to_owned());
1174            return Vec::new();
1175        };
1176        let s = s.trim();
1177        match serde_json::from_str::<BiomeOutput>(s) {
1178            Ok(output) => biome_to_events(output.diagnostics, run_id, offset_ms),
1179            Err(e) => {
1180                self.unknown_format = Some(format!("failed to parse Biome JSON: {e}"));
1181                Vec::new()
1182            }
1183        }
1184    }
1185
1186    fn unknown_format_reason(&self) -> Option<&str> {
1187        self.unknown_format.as_deref()
1188    }
1189}
1190
1191// ─── Biome JSON structs ───────────────────────────────────────────────────────
1192
1193#[derive(serde::Deserialize)]
1194struct BiomeOutput {
1195    #[serde(default)]
1196    diagnostics: Vec<BiomeDiagnostic>,
1197}
1198
1199#[derive(serde::Deserialize)]
1200struct BiomeDiagnostic {
1201    category: Option<String>,
1202    severity: String,
1203    description: String,
1204    location: Option<BiomeLocation>,
1205}
1206
1207#[derive(serde::Deserialize)]
1208struct BiomeLocation {
1209    path: Option<BiomePath>,
1210}
1211
1212#[derive(serde::Deserialize)]
1213struct BiomePath {
1214    file: Option<String>,
1215}
1216
1217fn biome_to_events(
1218    diagnostics: Vec<BiomeDiagnostic>,
1219    run_id: &crate::types::TaskRunId,
1220    offset_ms: u32,
1221) -> Vec<Event> {
1222    diagnostics.into_iter().map(|d| {
1223        let level = match d.severity.as_str() {
1224            "fatal" => Level::Fatal,
1225            "error" => Level::Error,
1226            "warning" => Level::Warn,
1227            "information" => Level::Info,
1228            "hint" => Level::Debug,
1229            _ => Level::Warn,
1230        };
1231        let mut fields = serde_json::json!({});
1232        if let Some(ref cat) = d.category {
1233            fields["error"] = serde_json::json!({ "code": cat });
1234        }
1235        if let Some(loc) = d.location {
1236            if let Some(path) = loc.path {
1237                if let Some(file) = path.file {
1238                    fields["file"] = serde_json::json!({ "path": file });
1239                }
1240            }
1241        }
1242        Event {
1243            run_id: run_id.clone(),
1244            seq: 0,
1245            offset_ms,
1246            level,
1247            target: "biome".to_owned(),
1248            msg: d.description,
1249            fields,
1250            anchor: None,
1251            source: EventSource::Beholder {
1252                name: "biome".to_owned(),
1253                version: "1.0".to_owned(),
1254            },
1255        }
1256    }).collect()
1257}
1258
1259// ─── JS test beholder (vitest / jest / bun-test) ─────────────────────────────
1260
1261/// Argv rewriter for vitest: adds `--reporter=json` when not already present.
1262fn vitest_adjust_argv(argv: &mut Vec<String>) {
1263    if !argv.iter().any(|a| a == "--reporter=json") {
1264        argv.push("--reporter=json".to_owned());
1265    }
1266}
1267
1268/// Argv rewriter for jest: adds `--json` when not already present.
1269fn jest_adjust_argv(argv: &mut Vec<String>) {
1270    if !argv.iter().any(|a| a == "--json") {
1271        argv.push("--json".to_owned());
1272    }
1273}
1274
1275/// Argv rewriter for bun test: adds `--reporter=json` when not already present.
1276fn bun_test_adjust_argv(argv: &mut Vec<String>) {
1277    if !argv.iter().any(|a| a == "--reporter=json") {
1278        argv.push("--reporter=json".to_owned());
1279    }
1280}
1281
1282/// Factory for the bundled vitest beholder (Tier 1.5, Rewriter mode).
1283///
1284/// Matches `vitest` invocations and rewrites argv to add `--reporter=json`,
1285/// which makes vitest emit a single Jest-compatible JSON document to stdout
1286/// at exit. Parsing happens in `on_done`.
1287///
1288/// Declines when:
1289/// - `--version` / `-v` are present (non-test invocations).
1290/// - `--reporter=<value>` is already set to a non-json reporter (respects
1291///   explicit user intent; if it's already `json`, rewriter is a no-op).
1292pub struct VitestBeholderFactory;
1293
1294impl BeholderFactory for VitestBeholderFactory {
1295    fn name(&self) -> &'static str { "vitest" }
1296    fn version(&self) -> &'static str { "1.0" }
1297
1298    fn matches(&self, argv: &[String]) -> bool {
1299        if argv.first().map(String::as_str) != Some("vitest") {
1300            return false;
1301        }
1302        if argv.iter().any(|a| matches!(a.as_str(), "--version" | "-v")) {
1303            return false;
1304        }
1305        // Decline if user set a non-json reporter (--reporter=<x> or --reporter <x>).
1306        let reporter_eq = argv.iter()
1307            .find(|a| a.starts_with("--reporter="))
1308            .map(|a| a.trim_start_matches("--reporter="));
1309        let reporter_space = argv.windows(2)
1310            .find(|w| w[0] == "--reporter")
1311            .map(|w| w[1].as_str());
1312        let explicit = reporter_eq.or(reporter_space);
1313        matches!(explicit, None | Some("json"))
1314    }
1315
1316    fn mode(&self) -> BeholderMode {
1317        BeholderMode::Rewriter { adjust_argv: vitest_adjust_argv }
1318    }
1319
1320    fn create(&self) -> Box<dyn Beholder> {
1321        Box::new(JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv))
1322    }
1323
1324    fn tool_version_range(&self) -> Option<ToolVersionRange> {
1325        Some(ToolVersionRange { min: Some("1.0.0"), max: None })
1326    }
1327}
1328
1329/// Factory for the bundled jest beholder (Tier 1.5, Rewriter mode).
1330///
1331/// Matches `jest` invocations and rewrites argv to add `--json`, which makes
1332/// jest emit a JSON test report to stdout at exit. Parsing happens in `on_done`.
1333///
1334/// Declines when:
1335/// - `--version` / `-v` are present.
1336/// - `--outputFile` is present (JSON would go to a file, not stdout).
1337pub struct JestBeholderFactory;
1338
1339impl BeholderFactory for JestBeholderFactory {
1340    fn name(&self) -> &'static str { "jest" }
1341    fn version(&self) -> &'static str { "27.0" }
1342
1343    fn matches(&self, argv: &[String]) -> bool {
1344        if argv.first().map(String::as_str) != Some("jest") {
1345            return false;
1346        }
1347        if argv.iter().any(|a| matches!(a.as_str(), "--version" | "-v")) {
1348            return false;
1349        }
1350        // --outputFile sends JSON to a file rather than stdout; we can't capture that.
1351        if argv.iter().any(|a| a.starts_with("--outputFile")) {
1352            return false;
1353        }
1354        true
1355    }
1356
1357    fn mode(&self) -> BeholderMode {
1358        BeholderMode::Rewriter { adjust_argv: jest_adjust_argv }
1359    }
1360
1361    fn create(&self) -> Box<dyn Beholder> {
1362        Box::new(JsTestBeholder::new("jest", "27.0", jest_adjust_argv))
1363    }
1364
1365    fn tool_version_range(&self) -> Option<ToolVersionRange> {
1366        Some(ToolVersionRange { min: Some("27.0.0"), max: None })
1367    }
1368}
1369
1370/// Factory for the bundled bun-test beholder (Tier 1.5, Rewriter mode).
1371///
1372/// Matches `bun test` invocations (argv[0]="bun", argv[1]="test") and rewrites
1373/// argv to add `--reporter=json`. Parsing happens in `on_done`.
1374///
1375/// Declines when:
1376/// - `--version` is present.
1377/// - `--reporter=<value>` is already set to a non-json reporter.
1378pub struct BunTestBeholderFactory;
1379
1380impl BeholderFactory for BunTestBeholderFactory {
1381    fn name(&self) -> &'static str { "bun-test" }
1382    fn version(&self) -> &'static str { "1.0" }
1383
1384    fn matches(&self, argv: &[String]) -> bool {
1385        if argv.first().map(String::as_str) != Some("bun") {
1386            return false;
1387        }
1388        if argv.get(1).map(String::as_str) != Some("test") {
1389            return false;
1390        }
1391        if argv.iter().any(|a| a == "--version") {
1392            return false;
1393        }
1394        let reporter = argv.iter()
1395            .find(|a| a.starts_with("--reporter="))
1396            .map(|a| a.trim_start_matches("--reporter="));
1397        matches!(reporter, None | Some("json"))
1398    }
1399
1400    fn mode(&self) -> BeholderMode {
1401        BeholderMode::Rewriter { adjust_argv: bun_test_adjust_argv }
1402    }
1403
1404    fn create(&self) -> Box<dyn Beholder> {
1405        Box::new(JsTestBeholder::new("bun-test", "1.0", bun_test_adjust_argv))
1406    }
1407
1408    fn tool_version_range(&self) -> Option<ToolVersionRange> {
1409        Some(ToolVersionRange { min: Some("1.0.0"), max: None })
1410    }
1411}
1412
1413/// Per-run JS test JSON parser shared by vitest, jest, and bun-test.
1414///
1415/// All three produce a Jest-compatible JSON report on stdout at process exit
1416/// (via `--reporter=json` for vitest/bun-test, `--json` for jest). There is no
1417/// streaming line-by-line format, so incremental parsing is not possible: the
1418/// beholder accumulates raw bytes and flushes structured events in `on_done`.
1419///
1420/// Events emitted:
1421/// - One summary event (Info on success, Error on failure) with pass/fail counts.
1422/// - One Error event per failed assertion with the test name and failure message.
1423pub struct JsTestBeholder {
1424    tool: &'static str,
1425    version: &'static str,
1426    adjust_argv_fn: fn(&mut Vec<String>),
1427    buf: Vec<u8>,
1428    unknown_format: Option<String>,
1429}
1430
1431impl JsTestBeholder {
1432    fn new(tool: &'static str, version: &'static str, adjust_argv_fn: fn(&mut Vec<String>)) -> Self {
1433        Self { tool, version, adjust_argv_fn, buf: Vec::new(), unknown_format: None }
1434    }
1435}
1436
1437impl Beholder for JsTestBeholder {
1438    fn name(&self) -> &'static str { self.tool }
1439    fn version(&self) -> &'static str { self.version }
1440    fn mode(&self) -> BeholderMode {
1441        BeholderMode::Rewriter { adjust_argv: self.adjust_argv_fn }
1442    }
1443
1444    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1445        self.buf.extend_from_slice(&chunk.bytes);
1446        Vec::new()
1447    }
1448
1449    fn on_done(&mut self, run_id: &crate::types::TaskRunId, offset_ms: u32) -> Vec<Event> {
1450        if self.buf.is_empty() {
1451            return Vec::new();
1452        }
1453        let Ok(s) = std::str::from_utf8(&self.buf) else {
1454            self.unknown_format = Some("non-UTF-8 output".to_owned());
1455            return Vec::new();
1456        };
1457        let s = s.trim();
1458        match serde_json::from_str::<JsTestReport>(s) {
1459            Ok(report) => js_test_to_events(report, run_id, offset_ms, self.tool, self.version),
1460            Err(e) => {
1461                self.unknown_format = Some(format!("failed to parse {} JSON: {e}", self.tool));
1462                Vec::new()
1463            }
1464        }
1465    }
1466
1467    fn unknown_format_reason(&self) -> Option<&str> {
1468        self.unknown_format.as_deref()
1469    }
1470}
1471
1472// ─── JS test JSON structs ─────────────────────────────────────────────────────
1473
1474#[derive(serde::Deserialize)]
1475struct JsTestReport {
1476    #[serde(default)]
1477    success: bool,
1478    #[serde(rename = "numTotalTests", default)]
1479    num_total: u32,
1480    #[serde(rename = "numPassedTests", default)]
1481    num_passed: u32,
1482    #[serde(rename = "numFailedTests", default)]
1483    num_failed: u32,
1484    #[serde(rename = "numPendingTests", default)]
1485    num_pending: u32,
1486    #[serde(rename = "testResults", default)]
1487    test_results: Vec<JsTestSuiteResult>,
1488}
1489
1490#[derive(serde::Deserialize)]
1491struct JsTestSuiteResult {
1492    #[serde(rename = "testFilePath")]
1493    file_path: String,
1494    #[serde(rename = "assertionResults", default)]
1495    assertions: Vec<JsTestAssertionResult>,
1496}
1497
1498#[derive(serde::Deserialize)]
1499struct JsTestAssertionResult {
1500    #[serde(rename = "fullName", default)]
1501    full_name: String,
1502    status: String,
1503    #[serde(rename = "failureMessages", default)]
1504    failure_messages: Vec<String>,
1505}
1506
1507fn js_test_to_events(
1508    report: JsTestReport,
1509    run_id: &crate::types::TaskRunId,
1510    offset_ms: u32,
1511    tool: &'static str,
1512    version: &'static str,
1513) -> Vec<Event> {
1514    let mut events = Vec::new();
1515
1516    // Summary event: one per run.
1517    let summary_msg = if report.success {
1518        format!("{} passed", report.num_passed)
1519    } else {
1520        format!("{} failed, {} passed", report.num_failed, report.num_passed)
1521    };
1522    events.push(Event {
1523        run_id: run_id.clone(),
1524        seq: 0,
1525        offset_ms,
1526        level: if report.success { Level::Info } else { Level::Error },
1527        target: tool.to_owned(),
1528        msg: summary_msg,
1529        fields: serde_json::json!({
1530            "test": {
1531                "total": report.num_total,
1532                "passed": report.num_passed,
1533                "failed": report.num_failed,
1534                "pending": report.num_pending,
1535            },
1536            "build": { "success": report.success }
1537        }),
1538        anchor: None,
1539        source: EventSource::Beholder {
1540            name: tool.to_owned(),
1541            version: version.to_owned(),
1542        },
1543    });
1544
1545    // One Error event per failed assertion.
1546    for suite in &report.test_results {
1547        for assertion in &suite.assertions {
1548            if assertion.status != "failed" {
1549                continue;
1550            }
1551            // Use the first failure message (first line only to keep events compact).
1552            let msg = assertion.failure_messages
1553                .first()
1554                .and_then(|s| s.lines().next())
1555                .map(str::to_owned)
1556                .unwrap_or_else(|| assertion.full_name.clone());
1557            events.push(Event {
1558                run_id: run_id.clone(),
1559                seq: 0,
1560                offset_ms,
1561                level: Level::Error,
1562                target: format!("{}::test", tool),
1563                msg,
1564                fields: serde_json::json!({
1565                    "test": { "name": assertion.full_name },
1566                    "file": { "path": suite.file_path },
1567                }),
1568                anchor: None,
1569                source: EventSource::Beholder {
1570                    name: tool.to_owned(),
1571                    version: version.to_owned(),
1572                },
1573            });
1574        }
1575    }
1576
1577    events
1578}
1579
1580// ─── pytest beholder ─────────────────────────────────────────────────────────
1581
1582/// Factory for the bundled pytest beholder (Tier 1.5, Parser mode).
1583///
1584/// Matches `pytest`, `py.test`, and `python[-3] -m pytest` invocations and
1585/// parses the standard human-readable output for structured events. Parser mode
1586/// is used because pytest-json-report is not universally available; the standard
1587/// text format is stable across all supported pytest versions (≥ 7).
1588///
1589/// Events emitted:
1590/// - One Error event per `FAILED`/`ERROR` line in the short test summary.
1591/// - One summary event (Info on success, Error on failure) from the final
1592///   `N failed, M passed[…] in Ws` separator line.
1593///
1594/// Declines when:
1595/// - `--version` / `-V` / `--help` / `-h` are present.
1596/// - `--collect-only` / `--co` are present (no test execution, different output).
1597pub struct PytestBeholderFactory;
1598
1599impl BeholderFactory for PytestBeholderFactory {
1600    fn name(&self) -> &'static str { "pytest" }
1601    fn version(&self) -> &'static str { "7.0" }
1602
1603    fn matches(&self, argv: &[String]) -> bool {
1604        let first = argv.first().map(String::as_str);
1605        let is_direct = matches!(first, Some("pytest") | Some("py.test"));
1606        let is_python_m = matches!(first, Some("python") | Some("python3"))
1607            && argv.windows(2).any(|w| w[0] == "-m" && w[1] == "pytest");
1608        if !is_direct && !is_python_m {
1609            return false;
1610        }
1611        for a in argv {
1612            match a.as_str() {
1613                "--version" | "-V" | "--help" | "-h" => return false,
1614                "--collect-only" | "--co" => return false,
1615                _ => {}
1616            }
1617        }
1618        true
1619    }
1620
1621    fn mode(&self) -> BeholderMode { BeholderMode::Parser }
1622
1623    fn create(&self) -> Box<dyn Beholder> { Box::new(PytestBeholder::default()) }
1624
1625    fn tool_version_range(&self) -> Option<ToolVersionRange> {
1626        Some(ToolVersionRange { min: Some("7.0.0"), max: None })
1627    }
1628}
1629
1630/// Per-run pytest text-output parser.
1631///
1632/// Processes lines incrementally. Emits:
1633/// - Error events for `FAILED`/`ERROR` entries in the short test summary section.
1634/// - A summary event from the final `=== N failed, M passed … ===` line.
1635pub struct PytestBeholder {
1636    buf: Vec<u8>,
1637    /// The inner text of the last `=== … ===` summary separator seen.
1638    summary_line: Option<String>,
1639    unknown_format: Option<String>,
1640}
1641
1642impl Default for PytestBeholder {
1643    fn default() -> Self {
1644        Self { buf: Vec::new(), summary_line: None, unknown_format: None }
1645    }
1646}
1647
1648impl Beholder for PytestBeholder {
1649    fn name(&self) -> &'static str { "pytest" }
1650    fn version(&self) -> &'static str { "7.0" }
1651    fn mode(&self) -> BeholderMode { BeholderMode::Parser }
1652
1653    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1654        self.buf.extend_from_slice(&chunk.bytes);
1655        let mut events = Vec::new();
1656
1657        let source = EventSource::Beholder {
1658            name: "pytest".to_owned(),
1659            version: "7.0".to_owned(),
1660        };
1661
1662        while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
1663            let raw: Vec<u8> = self.buf.drain(..=nl).collect();
1664            let Ok(line) = std::str::from_utf8(raw.trim_ascii_end()) else { continue };
1665            if line.is_empty() { continue }
1666
1667            if let Some(ev) = parse_pytest_failure_line(line, chunk, &source) {
1668                events.push(ev);
1669                continue;
1670            }
1671
1672            // Track the final `=== N failed, M passed … ===` line; may be updated
1673            // multiple times in watch/loop mode — we keep the latest.
1674            if line.starts_with("==") && line.ends_with("==") {
1675                let inner = line.trim_matches('=').trim();
1676                if inner.contains(" passed") || inner.contains(" failed")
1677                    || inner.contains(" error")
1678                {
1679                    self.summary_line = Some(inner.to_owned());
1680                }
1681            }
1682        }
1683
1684        events
1685    }
1686
1687    fn on_done(&mut self, run_id: &crate::types::TaskRunId, offset_ms: u32) -> Vec<Event> {
1688        let Some(ref summary) = self.summary_line else {
1689            return Vec::new();
1690        };
1691        let Ok(parsed) = parse_pytest_summary(summary) else {
1692            self.unknown_format = Some(format!("could not parse pytest summary: {summary:?}"));
1693            return Vec::new();
1694        };
1695
1696        let level = if parsed.failed > 0 || parsed.errors > 0 { Level::Error } else { Level::Info };
1697        let msg = format_pytest_summary_msg(&parsed);
1698
1699        vec![Event {
1700            run_id: run_id.clone(),
1701            seq: 0,
1702            offset_ms,
1703            level,
1704            target: "pytest".to_owned(),
1705            msg,
1706            fields: serde_json::json!({
1707                "test": {
1708                    "total":   parsed.passed + parsed.failed + parsed.skipped + parsed.errors,
1709                    "passed":  parsed.passed,
1710                    "failed":  parsed.failed,
1711                    "skipped": parsed.skipped,
1712                    "errors":  parsed.errors,
1713                },
1714                "build": { "success": parsed.failed == 0 && parsed.errors == 0 }
1715            }),
1716            anchor: None,
1717            source: EventSource::Beholder {
1718                name: "pytest".to_owned(),
1719                version: "7.0".to_owned(),
1720            },
1721        }]
1722    }
1723
1724    fn unknown_format_reason(&self) -> Option<&str> { self.unknown_format.as_deref() }
1725}
1726
1727// ─── pytest parsing helpers ───────────────────────────────────────────────────
1728
1729/// Parse a `FAILED` or `ERROR` short-summary line into an Event, or return None.
1730///
1731/// Formats handled:
1732/// - `FAILED tests/foo.py::test_name - AssertionError: assert 1 == 2`
1733/// - `ERROR tests/broken.py - ImportError: No module named 'x'`
1734fn parse_pytest_failure_line(
1735    line: &str,
1736    chunk: &OutputChunk,
1737    source: &EventSource,
1738) -> Option<Event> {
1739    let (label, rest) = if let Some(r) = line.strip_prefix("FAILED ") {
1740        ("FAILED", r)
1741    } else if let Some(r) = line.strip_prefix("ERROR ") {
1742        ("ERROR", r)
1743    } else {
1744        return None;
1745    };
1746
1747    // Require a `.py` in the nodeid portion to avoid false positives on
1748    // arbitrary lines that happen to start with these words.
1749    let (nodeid, reason) = if let Some(idx) = rest.find(" - ") {
1750        let nodeid = &rest[..idx];
1751        if !nodeid.contains(".py") { return None; }
1752        (nodeid, rest[idx + 3..].trim())
1753    } else {
1754        if !rest.contains(".py") { return None; }
1755        (rest, "")
1756    };
1757
1758    let (file_path, test_name) = if let Some(idx) = nodeid.find("::") {
1759        (&nodeid[..idx], &nodeid[idx + 2..])
1760    } else {
1761        (nodeid, "")
1762    };
1763
1764    let msg = if reason.is_empty() {
1765        format!("{label} {nodeid}")
1766    } else {
1767        reason.to_owned()
1768    };
1769
1770    Some(Event {
1771        run_id: chunk.run_id.clone(),
1772        seq: 0,
1773        offset_ms: chunk.offset_ms,
1774        level: Level::Error,
1775        target: "pytest::test".to_owned(),
1776        msg,
1777        fields: serde_json::json!({
1778            "test": { "name": test_name },
1779            "file": { "path": file_path },
1780        }),
1781        anchor: Some(ChunkRef { seq: chunk.seq }),
1782        source: source.clone(),
1783    })
1784}
1785
1786struct PytestSummary {
1787    passed:  u32,
1788    failed:  u32,
1789    skipped: u32,
1790    errors:  u32,
1791}
1792
1793/// Parse `"2 failed, 5 passed, 1 skipped in 1.23s"` → `PytestSummary`.
1794fn parse_pytest_summary(s: &str) -> Result<PytestSummary, ()> {
1795    // Strip duration suffix " in W.XXs" (optional — may be absent on early exit).
1796    let s = if let Some(idx) = s.rfind(" in ") { &s[..idx] } else { s };
1797
1798    let mut passed  = 0u32;
1799    let mut failed  = 0u32;
1800    let mut skipped = 0u32;
1801    let mut errors  = 0u32;
1802    let mut any = false;
1803
1804    for part in s.split(", ") {
1805        let part = part.trim();
1806        let mut it = part.splitn(2, ' ');
1807        let count: u32 = it.next().and_then(|n| n.parse().ok()).ok_or(())?;
1808        let kind = it.next().ok_or(())?;
1809        any = true;
1810        if kind.starts_with("passed")  { passed  = count; }
1811        else if kind.starts_with("failed")  { failed  = count; }
1812        else if kind.starts_with("skipped") { skipped = count; }
1813        else if kind.starts_with("error")   { errors  = count; }
1814        // Other kinds (warnings, deselected, xfailed, xpassed) pass through.
1815    }
1816
1817    if !any { return Err(()); }
1818    Ok(PytestSummary { passed, failed, skipped, errors })
1819}
1820
1821fn format_pytest_summary_msg(s: &PytestSummary) -> String {
1822    let mut parts: Vec<String> = Vec::new();
1823    if s.failed  > 0 { parts.push(format!("{} failed", s.failed)); }
1824    if s.errors  > 0 { parts.push(format!("{} error{}", s.errors, if s.errors == 1 { "" } else { "s" })); }
1825    if s.passed  > 0 { parts.push(format!("{} passed", s.passed)); }
1826    if s.skipped > 0 { parts.push(format!("{} skipped", s.skipped)); }
1827    if parts.is_empty() { return "no tests ran".to_owned(); }
1828    parts.join(", ")
1829}
1830
1831// ─── vite-build beholder ─────────────────────────────────────────────────────
1832
1833/// Factory for the bundled vite-build beholder (Tier 1.5, Parser mode).
1834///
1835/// Matches `vite build` invocations (bare or via wrapper: `bunx vite build`,
1836/// `pnpm vite build`). Parses the human-formatted output for `(!)` warnings,
1837/// per-asset bundle sizes, and the final `built in X.XXs` timing line.
1838///
1839/// Declines when:
1840/// - `argv[0]` (post-wrapper-stripping) is not `vite`.
1841/// - No `build` subcommand is present in argv.
1842/// - `--help` / `-h` / `--version` flags are present.
1843pub struct ViteBuildBeholderFactory;
1844
1845impl BeholderFactory for ViteBuildBeholderFactory {
1846    fn name(&self) -> &'static str { "vite-build" }
1847    fn version(&self) -> &'static str { "4.0" }
1848
1849    fn matches(&self, argv: &[String]) -> bool {
1850        if argv.first().map(String::as_str) != Some("vite") { return false; }
1851        if !argv.iter().skip(1).any(|a| a == "build") { return false; }
1852        for a in argv {
1853            match a.as_str() {
1854                "--help" | "-h" | "--version" => return false,
1855                _ => {}
1856            }
1857        }
1858        true
1859    }
1860
1861    fn mode(&self) -> BeholderMode { BeholderMode::Parser }
1862
1863    fn create(&self) -> Box<dyn Beholder> { Box::new(ViteBuildBeholder::default()) }
1864
1865    fn tool_version_range(&self) -> Option<ToolVersionRange> {
1866        Some(ToolVersionRange { min: Some("4.0.0"), max: None })
1867    }
1868}
1869
1870/// Per-run vite build output parser.
1871///
1872/// Processes lines incrementally. Emits:
1873/// - `Warn` events for `(!) <text>` warning lines (oversized chunks, eval, etc.)
1874/// - `Info` events for `dist/<file>  X kB  │ gzip: Y kB` bundle-output lines.
1875/// - An `Info` timing event from the `✓ built in X.XXs` line.
1876pub struct ViteBuildBeholder {
1877    buf: Vec<u8>,
1878    unknown_format: Option<String>,
1879}
1880
1881impl Default for ViteBuildBeholder {
1882    fn default() -> Self {
1883        Self { buf: Vec::new(), unknown_format: None }
1884    }
1885}
1886
1887impl Beholder for ViteBuildBeholder {
1888    fn name(&self) -> &'static str { "vite-build" }
1889    fn version(&self) -> &'static str { "4.0" }
1890    fn mode(&self) -> BeholderMode { BeholderMode::Parser }
1891
1892    fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1893        self.buf.extend_from_slice(&chunk.bytes);
1894        let mut events = Vec::new();
1895
1896        let source = EventSource::Beholder {
1897            name: "vite-build".to_owned(),
1898            version: "4.0".to_owned(),
1899        };
1900
1901        while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
1902            let raw: Vec<u8> = self.buf.drain(..=nl).collect();
1903            let Ok(line) = std::str::from_utf8(raw.trim_ascii_end()) else { continue };
1904            if line.is_empty() { continue }
1905
1906            // (!) warning lines
1907            if let Some(warn_text) = line.strip_prefix("(!) ") {
1908                events.push(Event {
1909                    run_id: chunk.run_id.clone(),
1910                    seq: 0,
1911                    offset_ms: chunk.offset_ms,
1912                    level: Level::Warn,
1913                    target: "vite-build::warning".to_owned(),
1914                    msg: warn_text.trim().to_owned(),
1915                    fields: serde_json::json!({ "build": { "warning": true } }),
1916                    anchor: Some(ChunkRef { seq: chunk.seq }),
1917                    source: source.clone(),
1918                });
1919                continue;
1920            }
1921
1922            // dist/<file>  X.XX kB  [│ gzip:  Y.YY kB]
1923            if let Some(ev) = parse_vite_bundle_line(line, chunk, &source) {
1924                events.push(ev);
1925                continue;
1926            }
1927
1928            // "✓ built in X.XXs" or "built in X.XXs"
1929            if let Some(ms) = parse_vite_built_ms(line) {
1930                events.push(Event {
1931                    run_id: chunk.run_id.clone(),
1932                    seq: 0,
1933                    offset_ms: chunk.offset_ms,
1934                    level: Level::Info,
1935                    target: "vite-build".to_owned(),
1936                    msg: format!("built in {:.2}s", ms as f64 / 1000.0),
1937                    fields: serde_json::json!({
1938                        "build": { "duration_ms": ms, "success": true }
1939                    }),
1940                    anchor: Some(ChunkRef { seq: chunk.seq }),
1941                    source: source.clone(),
1942                });
1943            }
1944        }
1945
1946        events
1947    }
1948
1949    fn unknown_format_reason(&self) -> Option<&str> { self.unknown_format.as_deref() }
1950}
1951
1952// ─── vite-build parsing helpers ───────────────────────────────────────────────
1953
1954/// Parse `dist/<file>  X.XX kB [│ gzip:  Y.YY kB]` into an Event, or None.
1955///
1956/// The `│` (U+2502) separator and gzip column are optional.
1957fn parse_vite_bundle_line(line: &str, chunk: &OutputChunk, source: &EventSource) -> Option<Event> {
1958    let trimmed = line.trim();
1959    if !trimmed.starts_with("dist/") { return None; }
1960    if !trimmed.contains("kB") { return None; }
1961
1962    let tokens: Vec<&str> = trimmed.split_whitespace().collect();
1963    let file = tokens.first()?;
1964
1965    // Collect all `float kB` pairs in order: first is size, second (if any) is gzip.
1966    let mut values: Vec<f64> = Vec::new();
1967    let mut i = 1;
1968    while i < tokens.len() {
1969        if let Ok(v) = tokens[i].parse::<f64>() {
1970            if tokens.get(i + 1).copied() == Some("kB") {
1971                values.push(v);
1972                i += 2;
1973                continue;
1974            }
1975        }
1976        i += 1;
1977    }
1978    let size_kb = *values.first()?;
1979    let gzip_kb = values.get(1).copied();
1980
1981    let msg = if let Some(g) = gzip_kb {
1982        format!("{file}: {size_kb:.2} kB (gzip: {g:.2} kB)")
1983    } else {
1984        format!("{file}: {size_kb:.2} kB")
1985    };
1986
1987    let mut fields = serde_json::json!({
1988        "file": { "path": file },
1989        "build": { "size_kb": size_kb },
1990    });
1991    if let Some(g) = gzip_kb {
1992        fields["build"]["gzip_kb"] = serde_json::json!(g);
1993    }
1994
1995    Some(Event {
1996        run_id: chunk.run_id.clone(),
1997        seq: 0,
1998        offset_ms: chunk.offset_ms,
1999        level: Level::Info,
2000        target: "vite-build::bundle".to_owned(),
2001        msg,
2002        fields,
2003        anchor: Some(ChunkRef { seq: chunk.seq }),
2004        source: source.clone(),
2005    })
2006}
2007
2008/// Parse `"✓ built in 2.43s"` or `"built in 2.43s"` → duration in ms.
2009fn parse_vite_built_ms(line: &str) -> Option<u64> {
2010    let idx = line.find("built in ")?;
2011    let rest = line[idx + "built in ".len()..].trim();
2012    let secs_str = rest.trim_end_matches(|c: char| c.is_alphabetic());
2013    let secs: f64 = secs_str.parse().ok()?;
2014    Some((secs * 1000.0).round() as u64)
2015}
2016
2017
2018// ─── Tests ────────────────────────────────────────────────────────────────────
2019
2020#[cfg(test)]
2021mod tests {
2022    use super::*;
2023    // ─── Test fixtures ────────────────────────────────────────────────────────
2024
2025    /// A no-op parser beholder that matches commands starting with a prefix.
2026    struct PrefixFactory {
2027        prefix: &'static str,
2028        name: &'static str,
2029    }
2030
2031    struct NoopBeholder {
2032        name: &'static str,
2033    }
2034
2035    impl BeholderFactory for PrefixFactory {
2036        fn name(&self) -> &'static str { self.name }
2037        fn version(&self) -> &'static str { "1.0" }
2038        fn matches(&self, argv: &[String]) -> bool {
2039            argv.first().map(|s| s.starts_with(self.prefix)).unwrap_or(false)
2040        }
2041        fn mode(&self) -> BeholderMode { BeholderMode::Parser }
2042        fn create(&self) -> Box<dyn Beholder> { Box::new(NoopBeholder { name: self.name }) }
2043    }
2044
2045    impl Beholder for NoopBeholder {
2046        fn name(&self) -> &'static str { self.name }
2047        fn version(&self) -> &'static str { "1.0" }
2048        fn mode(&self) -> BeholderMode { BeholderMode::Parser }
2049        fn parse_chunk(&mut self, _chunk: &OutputChunk) -> Vec<Event> { vec![] }
2050    }
2051
2052    /// A rewriter beholder that declines when --no-json is present.
2053    struct RewriterFactory;
2054    struct RewriterBeholder;
2055
2056    impl BeholderFactory for RewriterFactory {
2057        fn name(&self) -> &'static str { "cargo" }
2058        fn version(&self) -> &'static str { "1.78" }
2059        fn matches(&self, argv: &[String]) -> bool {
2060            argv.first().map(|s| s == "cargo").unwrap_or(false)
2061                && !argv.iter().any(|a| a == "--no-json")
2062        }
2063        fn mode(&self) -> BeholderMode {
2064            BeholderMode::Rewriter {
2065                adjust_argv: |argv| {
2066                    argv.push("--message-format=json-render-diagnostics".to_string());
2067                },
2068            }
2069        }
2070        fn create(&self) -> Box<dyn Beholder> { Box::new(RewriterBeholder) }
2071    }
2072
2073    impl Beholder for RewriterBeholder {
2074        fn name(&self) -> &'static str { "cargo" }
2075        fn version(&self) -> &'static str { "1.78" }
2076        fn mode(&self) -> BeholderMode {
2077            BeholderMode::Rewriter {
2078                adjust_argv: |argv| {
2079                    argv.push("--message-format=json-render-diagnostics".to_string());
2080                },
2081            }
2082        }
2083        fn parse_chunk(&mut self, _chunk: &OutputChunk) -> Vec<Event> { vec![] }
2084    }
2085
2086    // ─── Registry tests ───────────────────────────────────────────────────────
2087
2088    #[test]
2089    fn empty_registry_returns_none_auto() {
2090        let registry = BeholderRegistry::new();
2091        let result = registry.attach("cargo check", &BeholderSelect::Auto, false);
2092        assert!(result.beholder.is_none());
2093        assert_eq!(result.status.text, "none:auto");
2094        assert_eq!(result.argv, vec!["cargo", "check"]);
2095    }
2096
2097    #[test]
2098    fn auto_attaches_first_matching() {
2099        let mut registry = BeholderRegistry::new();
2100        registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "cargo" }));
2101        let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, false);
2102        assert!(result.beholder.is_some());
2103        assert_eq!(result.status.text, "attached:cargo@1.0");
2104    }
2105
2106    #[test]
2107    fn rewriter_adjusts_argv_in_result() {
2108        let mut registry = BeholderRegistry::new();
2109        registry.register(Box::new(RewriterFactory));
2110        let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, false);
2111        assert!(result.beholder.is_some());
2112        // Rewrite must be surfaced in status text and in rewrite_added.
2113        assert!(
2114            result.status.text.starts_with("attached:cargo@1.78"),
2115            "got: {}",
2116            result.status.text
2117        );
2118        assert!(
2119            result.status.text.contains("rewrite="),
2120            "rewrite must be surfaced in status; got: {}",
2121            result.status.text
2122        );
2123        assert!(
2124            result.argv.contains(&"--message-format=json-render-diagnostics".to_string()),
2125            "rewriter must append the JSON flag"
2126        );
2127        assert_eq!(
2128            result.status.rewrite_added.as_deref(),
2129            Some(vec!["--message-format=json-render-diagnostics".to_string()].as_slice()),
2130            "rewrite_added must list the injected arg"
2131        );
2132    }
2133
2134    #[test]
2135    fn auto_respects_decline_from_matches() {
2136        let mut registry = BeholderRegistry::new();
2137        registry.register(Box::new(RewriterFactory));
2138        let result = registry.attach("cargo check --no-json", &BeholderSelect::Auto, false);
2139        assert!(result.beholder.is_none());
2140        assert_eq!(result.status.text, "none:auto");
2141        // argv unchanged — no rewrite when beholder declined
2142        assert!(!result.argv.contains(&"--message-format=json-render-diagnostics".to_string()));
2143    }
2144
2145    /// R739-S2. The live failure this guard exists for: `yah build run`
2146    /// relocates an agent's whole bash line into `task.run`, and 76.5% of the
2147    /// lines in this camp are compound. Without the guard the rewriter's flag
2148    /// lands on the last token of the pipeline and the command dies with
2149    /// `tail: unrecognized option '--message-format=…'`.
2150    #[test]
2151    fn a_piped_line_gets_no_rewriter_because_the_flag_would_land_on_the_last_stage() {
2152        let mut registry = BeholderRegistry::new();
2153        registry.register(Box::new(RewriterFactory));
2154        let result = registry.attach(
2155            "cargo check -p yah-agent-tools --lib 2>&1 | tail -12",
2156            &BeholderSelect::Auto,
2157            false,
2158        );
2159        assert!(result.beholder.is_none(), "no beholder may attach to a shell line");
2160        assert_eq!(result.status.text, "declined:auto reason=\"compound-command\"");
2161        assert!(
2162            !result
2163                .argv
2164                .contains(&"--message-format=json-render-diagnostics".to_string()),
2165            "the flag must not be spliced into `tail`'s arguments"
2166        );
2167    }
2168
2169    /// Force is not an escape hatch here: forcing a rewriter onto a compound
2170    /// line breaks the command just as thoroughly as Auto would.
2171    #[test]
2172    fn force_does_not_override_the_compound_command_guard() {
2173        let mut registry = BeholderRegistry::new();
2174        registry.register(Box::new(RewriterFactory));
2175        let result = registry.attach(
2176            "cargo check && cargo test",
2177            &BeholderSelect::Force("cargo".to_string()),
2178            false,
2179        );
2180        assert!(result.beholder.is_none());
2181        assert_eq!(result.status.text, "declined:auto reason=\"compound-command\"");
2182    }
2183
2184    #[test]
2185    fn a_bare_command_still_attaches() {
2186        let mut registry = BeholderRegistry::new();
2187        registry.register(Box::new(RewriterFactory));
2188        let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, false);
2189        assert!(result.beholder.is_some(), "the guard must not cost the simple case");
2190    }
2191
2192    /// Over-declining is the deliberate error direction — see
2193    /// [`is_single_simple_command`]. Recorded so a later tightening is a
2194    /// choice rather than an accident.
2195    #[test]
2196    fn the_guard_is_a_character_scan_and_over_declines_on_purpose() {
2197        assert!(is_single_simple_command("cargo check --workspace"));
2198        assert!(is_single_simple_command("cargo test -p yah --lib mcp::tools"));
2199        // Genuinely compound.
2200        assert!(!is_single_simple_command("cargo check 2>&1 | tail -5"));
2201        assert!(!is_single_simple_command("cargo check && cargo test"));
2202        assert!(!is_single_simple_command("cargo check; echo done"));
2203        assert!(!is_single_simple_command("cargo check $(cat args)"));
2204        assert!(!is_single_simple_command("cargo check\ncargo test"));
2205        // Harmless in principle, declined anyway: no shell parser here.
2206        assert!(!is_single_simple_command("cargo check > out.txt"));
2207        assert!(!is_single_simple_command("cargo test -p yah -- --nocapture $FILTER"));
2208    }
2209
2210    #[test]
2211    fn explicit_none_bypasses_registry() {
2212        let mut registry = BeholderRegistry::new();
2213        registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "cargo" }));
2214        let result = registry.attach("cargo check", &BeholderSelect::None, false);
2215        assert!(result.beholder.is_none());
2216        assert_eq!(result.status.text, "none:explicit");
2217    }
2218
2219    #[test]
2220    fn force_pins_by_name() {
2221        let mut registry = BeholderRegistry::new();
2222        registry.register(Box::new(PrefixFactory { prefix: "tsc", name: "tsc" }));
2223        registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "cargo" }));
2224        // Force "cargo" even though Auto would have matched "tsc" with "tsc" prefix
2225        let result = registry.attach("cargo check", &BeholderSelect::Force("cargo".to_string()), false);
2226        assert!(result.beholder.is_some());
2227        assert!(result.status.text.starts_with("forced:cargo"), "got: {}", result.status.text);
2228    }
2229
2230    #[test]
2231    fn force_against_flags_when_would_decline() {
2232        let mut registry = BeholderRegistry::new();
2233        registry.register(Box::new(RewriterFactory));
2234        // cargo with --no-json would normally decline; Force overrides
2235        let result =
2236            registry.attach("cargo check --no-json", &BeholderSelect::Force("cargo".to_string()), false);
2237        assert!(result.beholder.is_some());
2238        assert!(
2239            result.status.text.contains("forced-against-flags"),
2240            "got: {}",
2241            result.status.text
2242        );
2243    }
2244
2245    #[test]
2246    fn force_unknown_name_returns_none() {
2247        let registry = BeholderRegistry::new();
2248        let result =
2249            registry.attach("cargo check", &BeholderSelect::Force("unknown".to_string()), false);
2250        assert!(result.beholder.is_none());
2251    }
2252
2253    #[test]
2254    fn priority_order_first_match_wins() {
2255        let mut registry = BeholderRegistry::new();
2256        registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "first" }));
2257        registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "second" }));
2258        let result = registry.attach("cargo check", &BeholderSelect::Auto, false);
2259        assert_eq!(result.status.text, "attached:first@1.0");
2260    }
2261
2262    // ─── verbatim-output behaviour tests ──────────────────────────────────────
2263
2264    #[test]
2265    fn verbatim_output_causes_rewriter_to_decline_in_auto() {
2266        let mut registry = BeholderRegistry::new();
2267        registry.register(Box::new(RewriterFactory));
2268        let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, true);
2269        // Beholder declines rather than change what the reader sees.
2270        assert!(result.beholder.is_none(), "rewriter must not attach under verbatim_output");
2271        assert!(
2272            result.status.text.contains("declined:cargo"),
2273            "got: {}",
2274            result.status.text
2275        );
2276        assert!(
2277            result.status.text.contains("verbatim-output"),
2278            "got: {}",
2279            result.status.text
2280        );
2281        // argv must be unchanged — no rewrite applied.
2282        assert!(!result.argv.contains(&"--message-format=json-render-diagnostics".to_string()));
2283    }
2284
2285    /// R739-B10, against the REAL cargo factory rather than the test double.
2286    ///
2287    /// The double proves the registry's rule; this proves the rule reaches the
2288    /// beholder that actually shipped the defect. A relocated `cargo check`
2289    /// that succeeds emits one `compiler-artifact` record per crate, and with
2290    /// the rewrite applied every one of them lands on the agent's stdout as raw
2291    /// JSON — observed live 2026-08-28.
2292    #[test]
2293    fn the_real_cargo_rewriter_declines_when_the_output_is_read_verbatim() {
2294        let mut registry = BeholderRegistry::new();
2295        registry.register(Box::new(CargoBeholderFactory));
2296        let result = registry.attach("cargo check -p yah --lib", &BeholderSelect::Auto, true);
2297        assert!(result.beholder.is_none(), "got: {}", result.status.text);
2298        assert!(
2299            !result.argv.contains(&"--message-format=json-render-diagnostics".to_string()),
2300            "the command line must reach the shell exactly as the caller wrote it: {:?}",
2301            result.argv
2302        );
2303        // …and the same line with nobody reading the bytes still gets the
2304        // structured events, so this is a targeted decline and not a disabling.
2305        let observed = registry.attach("cargo check -p yah --lib", &BeholderSelect::Auto, false);
2306        assert!(observed.beholder.is_some(), "got: {}", observed.status.text);
2307    }
2308
2309    #[test]
2310    fn verbatim_output_does_not_affect_parser_beholder() {
2311        let mut registry = BeholderRegistry::new();
2312        registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "cargo" }));
2313        // Parser beholders never touch argv, so they change nothing the reader
2314        // sees and have no reason to decline.
2315        let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, true);
2316        assert!(
2317            result.beholder.is_some(),
2318            "parser beholder must attach even under verbatim_output"
2319        );
2320        assert_eq!(result.status.text, "attached:cargo@1.0");
2321    }
2322
2323    #[test]
2324    fn force_overrides_verbatim_decline_and_records_forced_against_verbatim() {
2325        let mut registry = BeholderRegistry::new();
2326        registry.register(Box::new(RewriterFactory));
2327        // Force overrides the decline; the operator explicitly wants structured
2328        // output and accepts what it does to the stream.
2329        let result =
2330            registry.attach("cargo check", &BeholderSelect::Force("cargo".to_string()), true);
2331        assert!(result.beholder.is_some(), "Force must attach under verbatim_output");
2332        assert!(
2333            result.status.text.contains("forced-against-verbatim"),
2334            "got: {}",
2335            result.status.text
2336        );
2337        // Rewrite still applied (forced) and surfaced.
2338        assert!(
2339            result.argv.contains(&"--message-format=json-render-diagnostics".to_string()),
2340            "rewrite must still be applied when forced"
2341        );
2342    }
2343
2344    #[test]
2345    fn rewrite_surfaced_on_force_normal() {
2346        let mut registry = BeholderRegistry::new();
2347        registry.register(Box::new(RewriterFactory));
2348        let result =
2349            registry.attach("cargo check", &BeholderSelect::Force("cargo".to_string()), false);
2350        assert!(result.beholder.is_some());
2351        assert!(
2352            result.status.text.contains("forced:cargo"),
2353            "got: {}",
2354            result.status.text
2355        );
2356        assert!(
2357            result.status.text.contains("rewrite="),
2358            "rewrite must be surfaced on forced attach; got: {}",
2359            result.status.text
2360        );
2361        assert!(result.status.rewrite_added.is_some());
2362    }
2363
2364    // ─── argv resolution tests ────────────────────────────────────────────────
2365
2366    #[test]
2367    fn resolve_bare_command() {
2368        assert_eq!(resolve_argv("cargo check --workspace"), ["cargo", "check", "--workspace"]);
2369    }
2370
2371    #[test]
2372    fn resolve_strips_bunx() {
2373        let argv = resolve_argv("bunx vitest --run");
2374        assert_eq!(argv[0], "vitest");
2375        assert_eq!(argv[1], "--run");
2376    }
2377
2378    #[test]
2379    fn resolve_strips_npx() {
2380        let argv = resolve_argv("npx tsc --noEmit");
2381        assert_eq!(argv[0], "tsc");
2382        assert_eq!(argv[1], "--noEmit");
2383    }
2384
2385    #[test]
2386    fn resolve_strips_pnpm() {
2387        let argv = resolve_argv("pnpm tsc");
2388        assert_eq!(argv[0], "tsc");
2389    }
2390
2391    #[test]
2392    fn resolve_strips_npm_exec() {
2393        let argv = resolve_argv("npm exec tsc --noEmit");
2394        assert_eq!(argv[0], "tsc");
2395        assert_eq!(argv[1], "--noEmit");
2396    }
2397
2398    #[test]
2399    fn resolve_pnpm_without_exec_subcommand() {
2400        // `pnpm tsc` — no exec subcommand, just strip pnpm
2401        let argv = resolve_argv("pnpm vitest --reporter=verbose");
2402        assert_eq!(argv[0], "vitest");
2403    }
2404
2405    #[test]
2406    fn resolve_empty_command() {
2407        let argv = resolve_argv("");
2408        assert!(argv.is_empty());
2409    }
2410
2411    // ─── CargoBeholderFactory tests ───────────────────────────────────────────
2412
2413    fn dummy_chunk(bytes: &[u8]) -> OutputChunk {
2414        use crate::types::{Stream, TaskRunId};
2415        OutputChunk {
2416            run_id: TaskRunId::new(),
2417            seq: 0,
2418            offset_ms: 0,
2419            stream: Stream::Stdout,
2420            bytes: bytes.to_vec(),
2421        }
2422    }
2423
2424    #[test]
2425    fn cargo_factory_matches_check() {
2426        let f = CargoBeholderFactory;
2427        let argv: Vec<String> = vec!["cargo".into(), "check".into(), "--workspace".into()];
2428        assert!(f.matches(&argv));
2429    }
2430
2431    #[test]
2432    fn cargo_factory_matches_clippy() {
2433        let f = CargoBeholderFactory;
2434        let argv: Vec<String> = vec!["cargo".into(), "clippy".into()];
2435        assert!(f.matches(&argv));
2436    }
2437
2438    #[test]
2439    fn cargo_factory_declines_explicit_message_format() {
2440        let f = CargoBeholderFactory;
2441        let argv: Vec<String> =
2442            vec!["cargo".into(), "check".into(), "--message-format=human".into()];
2443        assert!(!f.matches(&argv));
2444    }
2445
2446    #[test]
2447    fn cargo_factory_declines_non_diag_subcommand() {
2448        let f = CargoBeholderFactory;
2449        let argv: Vec<String> = vec!["cargo".into(), "fmt".into()];
2450        assert!(!f.matches(&argv));
2451    }
2452
2453    #[test]
2454    fn cargo_factory_declines_non_cargo() {
2455        let f = CargoBeholderFactory;
2456        let argv: Vec<String> = vec!["rustc".into(), "--edition=2021".into()];
2457        assert!(!f.matches(&argv));
2458    }
2459
2460    #[test]
2461    fn cargo_factory_rewriter_adds_flag() {
2462        let registry = default_registry();
2463        let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, false);
2464        assert!(result.beholder.is_some(), "cargo beholder must attach");
2465        assert!(
2466            result.argv.contains(&"--message-format=json-render-diagnostics".to_string()),
2467            "rewriter must inject the JSON flag; got: {:?}",
2468            result.argv
2469        );
2470        // Rewrite must be visible on status.
2471        assert!(
2472            result.status.text.contains("rewrite="),
2473            "rewrite must be surfaced in status; got: {}",
2474            result.status.text
2475        );
2476    }
2477
2478    #[test]
2479    fn cargo_beholder_parses_compiler_error() {
2480        let json = r#"{"reason":"compiler-message","package_id":"foo","manifest_path":"foo","target":{"kind":["lib"],"name":"foo","src_path":"src/lib.rs","edition":"2021","doctest":true,"test":true,"doc":true},"message":{"$message_type":"diagnostic","message":"mismatched types","code":{"code":"E0308","explanation":""},"level":"error","spans":[{"file_name":"src/lib.rs","byte_start":0,"byte_end":1,"line_start":5,"line_end":5,"column_start":1,"column_end":14,"is_primary":true,"text":[],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"error[E0308]: mismatched types\n"}}"#;
2481        let chunk = dummy_chunk(format!("{json}\n").as_bytes());
2482        let mut b = CargoBeholder::default();
2483        let events = b.parse_chunk(&chunk);
2484        assert_eq!(events.len(), 1);
2485        let ev = &events[0];
2486        assert_eq!(ev.target, "cargo::rustc");
2487        assert_eq!(ev.level, Level::Error);
2488        assert_eq!(ev.msg, "mismatched types");
2489        assert_eq!(ev.fields["error"]["code"], "E0308");
2490        assert_eq!(ev.fields["file"]["path"], "src/lib.rs");
2491        assert_eq!(ev.fields["file"]["line"], 5u32);
2492    }
2493
2494    #[test]
2495    fn cargo_beholder_parses_warning() {
2496        let json = r#"{"reason":"compiler-message","package_id":"foo","manifest_path":"foo","target":{"kind":["lib"],"name":"foo","src_path":"src/lib.rs","edition":"2021","doctest":true,"test":true,"doc":true},"message":{"$message_type":"diagnostic","message":"unused variable: `x`","code":{"code":"unused_variables","explanation":""},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":0,"byte_end":1,"line_start":10,"line_end":10,"column_start":9,"column_end":10,"is_primary":true,"text":[],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"warning: unused variable"}}"#;
2497        let chunk = dummy_chunk(format!("{json}\n").as_bytes());
2498        let mut b = CargoBeholder::default();
2499        let events = b.parse_chunk(&chunk);
2500        assert_eq!(events.len(), 1);
2501        assert_eq!(events[0].level, Level::Warn);
2502        assert_eq!(events[0].fields["file"]["line"], 10u32);
2503    }
2504
2505    #[test]
2506    fn cargo_beholder_parses_build_finished_success() {
2507        let json = r#"{"reason":"build-finished","success":true}"#;
2508        let chunk = dummy_chunk(format!("{json}\n").as_bytes());
2509        let mut b = CargoBeholder::default();
2510        let events = b.parse_chunk(&chunk);
2511        assert_eq!(events.len(), 1);
2512        assert_eq!(events[0].level, Level::Info);
2513        assert_eq!(events[0].msg, "build finished");
2514    }
2515
2516    #[test]
2517    fn cargo_beholder_parses_build_finished_failure() {
2518        let json = r#"{"reason":"build-finished","success":false}"#;
2519        let chunk = dummy_chunk(format!("{json}\n").as_bytes());
2520        let mut b = CargoBeholder::default();
2521        let events = b.parse_chunk(&chunk);
2522        assert_eq!(events.len(), 1);
2523        assert_eq!(events[0].level, Level::Error);
2524        assert_eq!(events[0].msg, "build failed");
2525    }
2526
2527    #[test]
2528    fn cargo_beholder_skips_artifacts_and_unknown() {
2529        // compiler-artifact and build-script-executed should produce no events
2530        let artifact = r#"{"reason":"compiler-artifact","package_id":"foo","manifest_path":"foo","target":{"kind":["lib"],"name":"foo","src_path":"src/lib.rs","edition":"2021","doctest":true,"test":true,"doc":true},"profile":{"opt_level":"0","debuginfo":2,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":[],"executable":null,"fresh":false}"#;
2531        let script = r#"{"reason":"build-script-executed","package_id":"foo 0.1.0","linked_libs":[],"linked_paths":[],"cfgs":[],"env":[],"out_dir":"/tmp"}"#;
2532        let mut b = CargoBeholder::default();
2533        let chunk = dummy_chunk(format!("{artifact}\n{script}\n").as_bytes());
2534        let events = b.parse_chunk(&chunk);
2535        assert!(events.is_empty(), "got: {events:?}");
2536    }
2537
2538    #[test]
2539    fn cargo_beholder_buffers_partial_lines() {
2540        let json = r#"{"reason":"build-finished","success":true}"#;
2541        let half = json.len() / 2;
2542        let mut b = CargoBeholder::default();
2543        // First half — no newline yet, no events
2544        let chunk1 = dummy_chunk(json[..half].as_bytes());
2545        let ev1 = b.parse_chunk(&chunk1);
2546        assert!(ev1.is_empty(), "should buffer partial line");
2547        // Second half + newline — now the event appears
2548        let chunk2 = dummy_chunk(format!("{}\n", &json[half..]).as_bytes());
2549        let ev2 = b.parse_chunk(&chunk2);
2550        assert_eq!(ev2.len(), 1);
2551        assert_eq!(ev2[0].msg, "build finished");
2552    }
2553
2554    // ─── Schema versioning + unknown-format fallback tests ────────────────────
2555
2556    #[test]
2557    fn cargo_beholder_unknown_format_reason_none_initially() {
2558        let b = CargoBeholder::default();
2559        assert!(b.unknown_format_reason().is_none(), "fresh beholder must not flag unknown format");
2560    }
2561
2562    #[test]
2563    fn cargo_factory_declares_tool_version_range() {
2564        let f = CargoBeholderFactory;
2565        let range = f.tool_version_range().expect("cargo factory must declare a version range");
2566        assert_eq!(range.min, Some("1.38.0"), "min version must be 1.38.0");
2567        assert!(range.max.is_none(), "no upper bound declared");
2568    }
2569
2570    #[test]
2571    fn cargo_beholder_format_probe_fires_after_limit() {
2572        // Feed FORMAT_PROBE_LIMIT JSON-object lines that lack a `reason` field.
2573        let bad_line = r#"{"not_reason":"something","value":42}"#;
2574        let input: String = (0..super::FORMAT_PROBE_LIMIT)
2575            .map(|_| format!("{bad_line}\n"))
2576            .collect();
2577        let chunk = dummy_chunk(input.as_bytes());
2578        let mut b = CargoBeholder::default();
2579        let events = b.parse_chunk(&chunk);
2580        assert!(events.is_empty(), "no events from unrecognized lines");
2581        let reason = b.unknown_format_reason();
2582        assert!(
2583            reason.is_some(),
2584            "unknown_format_reason must be Some after {FORMAT_PROBE_LIMIT} unrecognized JSON lines"
2585        );
2586        assert!(
2587            reason.unwrap().contains("no recognizable cargo JSON"),
2588            "reason text must describe the problem; got: {:?}",
2589            reason
2590        );
2591    }
2592
2593    #[test]
2594    fn cargo_beholder_format_probe_not_triggered_below_limit() {
2595        // One fewer than the limit — not yet flagged.
2596        let bad_line = r#"{"not_reason":"something"}"#;
2597        let input: String = (0..super::FORMAT_PROBE_LIMIT - 1)
2598            .map(|_| format!("{bad_line}\n"))
2599            .collect();
2600        let chunk = dummy_chunk(input.as_bytes());
2601        let mut b = CargoBeholder::default();
2602        b.parse_chunk(&chunk);
2603        assert!(
2604            b.unknown_format_reason().is_none(),
2605            "must not flag unknown format before probe limit"
2606        );
2607    }
2608
2609    #[test]
2610    fn cargo_beholder_format_probe_suppressed_after_recognized_line() {
2611        // One valid cargo line clears the probe; subsequent bad lines are silently dropped.
2612        let good = r#"{"reason":"build-finished","success":true}"#;
2613        let bad_line = r#"{"not_reason":"something"}"#;
2614        // Build more bad lines than the threshold.
2615        let mut input = format!("{good}\n");
2616        for _ in 0..super::FORMAT_PROBE_LIMIT + 2 {
2617            input.push_str(bad_line);
2618            input.push('\n');
2619        }
2620        let chunk = dummy_chunk(input.as_bytes());
2621        let mut b = CargoBeholder::default();
2622        let events = b.parse_chunk(&chunk);
2623        // The good line produces one event.
2624        assert_eq!(events.len(), 1, "expected one event from the recognized line");
2625        assert_eq!(events[0].msg, "build finished");
2626        assert!(
2627            b.unknown_format_reason().is_none(),
2628            "format probe must not fire after a recognized line has been seen"
2629        );
2630    }
2631
2632    #[test]
2633    fn cargo_beholder_stops_emitting_after_unknown_format() {
2634        // Trigger unknown format, then verify subsequent chunks produce no events.
2635        let bad_line = r#"{"not_reason":"x"}"#;
2636        let trigger: String = (0..super::FORMAT_PROBE_LIMIT)
2637            .map(|_| format!("{bad_line}\n"))
2638            .collect();
2639        let mut b = CargoBeholder::default();
2640        b.parse_chunk(&dummy_chunk(trigger.as_bytes()));
2641        assert!(b.unknown_format_reason().is_some(), "format must be flagged");
2642
2643        // Now feed a valid cargo line — must produce no events (beholder silenced).
2644        let valid = r#"{"reason":"build-finished","success":true}"#.to_string() + "\n";
2645        let events = b.parse_chunk(&dummy_chunk(valid.as_bytes()));
2646        assert!(events.is_empty(), "beholder must not emit events after unknown_format is set");
2647    }
2648
2649    #[test]
2650    fn cargo_beholder_non_json_lines_dont_count_toward_probe() {
2651        // Non-JSON lines (and empty lines) are skipped; they must not consume probe budget.
2652        let mut input = String::new();
2653        // More non-JSON lines than FORMAT_PROBE_LIMIT.
2654        for _ in 0..super::FORMAT_PROBE_LIMIT + 3 {
2655            input.push_str("  warning: some human text\n");
2656        }
2657        // Then a valid cargo line.
2658        input.push_str(r#"{"reason":"build-finished","success":true}"#);
2659        input.push('\n');
2660        let mut b = CargoBeholder::default();
2661        let events = b.parse_chunk(&dummy_chunk(input.as_bytes()));
2662        assert_eq!(events.len(), 1, "valid cargo line must still produce an event");
2663        assert!(b.unknown_format_reason().is_none(), "non-JSON lines must not count toward probe");
2664    }
2665
2666    // ─── TscBeholderFactory tests ─────────────────────────────────────────────
2667
2668    #[test]
2669    fn tsc_factory_matches_bare_tsc() {
2670        let f = TscBeholderFactory;
2671        let argv: Vec<String> = vec!["tsc".into(), "--noEmit".into()];
2672        assert!(f.matches(&argv));
2673    }
2674
2675    #[test]
2676    fn tsc_factory_matches_tsc_with_project() {
2677        let f = TscBeholderFactory;
2678        let argv: Vec<String> = vec!["tsc".into(), "-p".into(), "tsconfig.json".into()];
2679        assert!(f.matches(&argv));
2680    }
2681
2682    #[test]
2683    fn tsc_factory_matches_when_pretty_false_already_set() {
2684        // Should still attach (rewriter becomes a no-op; we still parse).
2685        let f = TscBeholderFactory;
2686        let argv: Vec<String> = vec!["tsc".into(), "--noEmit".into(), "--pretty=false".into()];
2687        assert!(f.matches(&argv));
2688    }
2689
2690    #[test]
2691    fn tsc_factory_declines_pretty_true() {
2692        let f = TscBeholderFactory;
2693        let argv: Vec<String> = vec!["tsc".into(), "--pretty=true".into()];
2694        assert!(!f.matches(&argv));
2695    }
2696
2697    #[test]
2698    fn tsc_factory_declines_bare_pretty() {
2699        let f = TscBeholderFactory;
2700        let argv: Vec<String> = vec!["tsc".into(), "--pretty".into()];
2701        assert!(!f.matches(&argv));
2702    }
2703
2704    #[test]
2705    fn tsc_factory_declines_version() {
2706        let f = TscBeholderFactory;
2707        let argv: Vec<String> = vec!["tsc".into(), "--version".into()];
2708        assert!(!f.matches(&argv));
2709    }
2710
2711    #[test]
2712    fn tsc_factory_declines_init() {
2713        let f = TscBeholderFactory;
2714        let argv: Vec<String> = vec!["tsc".into(), "--init".into()];
2715        assert!(!f.matches(&argv));
2716    }
2717
2718    #[test]
2719    fn tsc_factory_declines_non_tsc_command() {
2720        let f = TscBeholderFactory;
2721        let argv: Vec<String> = vec!["node".into(), "build.js".into()];
2722        assert!(!f.matches(&argv));
2723    }
2724
2725    #[test]
2726    fn tsc_factory_rewriter_adds_pretty_false() {
2727        let registry = default_registry();
2728        let result = registry.attach("tsc --noEmit", &BeholderSelect::Auto, false);
2729        assert!(result.beholder.is_some(), "tsc beholder must attach");
2730        assert!(
2731            result.argv.contains(&"--pretty=false".to_string()),
2732            "rewriter must inject --pretty=false; got: {:?}",
2733            result.argv
2734        );
2735        assert!(
2736            result.status.text.contains("rewrite="),
2737            "rewrite must be surfaced in status; got: {}",
2738            result.status.text
2739        );
2740    }
2741
2742    #[test]
2743    fn tsc_factory_no_rewrite_when_pretty_false_present() {
2744        let registry = default_registry();
2745        let result = registry.attach("tsc --noEmit --pretty=false", &BeholderSelect::Auto, false);
2746        assert!(result.beholder.is_some(), "tsc beholder must attach");
2747        // Argv already had --pretty=false; rewriter is a no-op, no duplicate added.
2748        let count = result.argv.iter().filter(|a| a.as_str() == "--pretty=false").count();
2749        assert_eq!(count, 1, "--pretty=false must appear exactly once; got: {:?}", result.argv);
2750        // No rewrite_added because the flag was already present.
2751        assert!(
2752            result.status.rewrite_added.as_ref().map(|v| v.is_empty()).unwrap_or(true),
2753            "rewrite_added must be empty when flag already present"
2754        );
2755    }
2756
2757    #[test]
2758    fn tsc_beholder_parses_error_line() {
2759        let line = "src/foo.ts(10,5): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.\n";
2760        let mut b = TscBeholder::default();
2761        let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2762        assert_eq!(events.len(), 1);
2763        let ev = &events[0];
2764        assert_eq!(ev.target, "tsc");
2765        assert_eq!(ev.level, Level::Error);
2766        assert_eq!(ev.msg, "Argument of type 'string' is not assignable to parameter of type 'number'.");
2767        assert_eq!(ev.fields["error"]["code"], "TS2345");
2768        assert_eq!(ev.fields["file"]["path"], "src/foo.ts");
2769        assert_eq!(ev.fields["file"]["line"], 10u32);
2770        assert_eq!(ev.fields["file"]["col"], 5u32);
2771    }
2772
2773    #[test]
2774    fn tsc_beholder_parses_warning_line() {
2775        let line = "src/bar.tsx(42,3): warning TS6133: 'x' is declared but its value is never read.\n";
2776        let mut b = TscBeholder::default();
2777        let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2778        assert_eq!(events.len(), 1);
2779        assert_eq!(events[0].level, Level::Warn);
2780        assert_eq!(events[0].fields["error"]["code"], "TS6133");
2781        assert_eq!(events[0].fields["file"]["path"], "src/bar.tsx");
2782        assert_eq!(events[0].fields["file"]["line"], 42u32);
2783        assert_eq!(events[0].fields["file"]["col"], 3u32);
2784    }
2785
2786    #[test]
2787    fn tsc_beholder_parses_summary_with_errors() {
2788        let line = "Found 2 errors.\n";
2789        let mut b = TscBeholder::default();
2790        let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2791        assert_eq!(events.len(), 1);
2792        assert_eq!(events[0].level, Level::Error);
2793        assert_eq!(events[0].fields["build"]["errors"], 2u32);
2794    }
2795
2796    #[test]
2797    fn tsc_beholder_parses_summary_zero_errors() {
2798        let line = "Found 0 errors.\n";
2799        let mut b = TscBeholder::default();
2800        let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2801        assert_eq!(events.len(), 1);
2802        assert_eq!(events[0].level, Level::Info);
2803        assert_eq!(events[0].fields["build"]["errors"], 0u32);
2804    }
2805
2806    #[test]
2807    fn tsc_beholder_parses_summary_in_n_files() {
2808        let line = "Found 3 errors in 2 files.\n";
2809        let mut b = TscBeholder::default();
2810        let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2811        assert_eq!(events.len(), 1);
2812        assert_eq!(events[0].fields["build"]["errors"], 3u32);
2813    }
2814
2815    #[test]
2816    fn tsc_beholder_skips_watch_mode_header() {
2817        let line = "[12:00:00 AM] Starting compilation in watch mode...\n";
2818        let mut b = TscBeholder::default();
2819        let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2820        assert!(events.is_empty(), "watch-mode header must be skipped");
2821    }
2822
2823    #[test]
2824    fn tsc_beholder_skips_blank_lines() {
2825        let input = "\n\n";
2826        let mut b = TscBeholder::default();
2827        let events = b.parse_chunk(&dummy_chunk(input.as_bytes()));
2828        assert!(events.is_empty());
2829    }
2830
2831    #[test]
2832    fn tsc_beholder_buffers_partial_lines() {
2833        let line = "src/foo.ts(1,1): error TS2304: Cannot find name 'foo'.";
2834        let half = line.len() / 2;
2835        let mut b = TscBeholder::default();
2836        let ev1 = b.parse_chunk(&dummy_chunk(line[..half].as_bytes()));
2837        assert!(ev1.is_empty(), "partial line must not produce events");
2838        let ev2 = b.parse_chunk(&dummy_chunk(format!("{}\n", &line[half..]).as_bytes()));
2839        assert_eq!(ev2.len(), 1, "complete line must produce one event");
2840        assert_eq!(ev2[0].fields["error"]["code"], "TS2304");
2841    }
2842
2843    #[test]
2844    fn tsc_beholder_multiple_errors_in_one_chunk() {
2845        let input = concat!(
2846            "src/a.ts(1,1): error TS2304: Cannot find name 'a'.\n",
2847            "src/b.ts(2,3): error TS2304: Cannot find name 'b'.\n",
2848            "Found 2 errors.\n",
2849        );
2850        let mut b = TscBeholder::default();
2851        let events = b.parse_chunk(&dummy_chunk(input.as_bytes()));
2852        assert_eq!(events.len(), 3, "two diagnostics + one summary");
2853        assert_eq!(events[0].fields["file"]["path"], "src/a.ts");
2854        assert_eq!(events[1].fields["file"]["path"], "src/b.ts");
2855        assert_eq!(events[2].fields["build"]["errors"], 2u32);
2856    }
2857
2858    #[test]
2859    fn tsc_beholder_full_watch_cycle() {
2860        let input = concat!(
2861            "[12:00:00 AM] Starting compilation in watch mode...\n",
2862            "\n",
2863            "src/foo.ts(5,9): error TS2345: wrong type.\n",
2864            "\n",
2865            "[12:00:01 AM] Found 1 error. Watching for file changes.\n",
2866        );
2867        let mut b = TscBeholder::default();
2868        let events = b.parse_chunk(&dummy_chunk(input.as_bytes()));
2869        // Only the diagnostic line produces an event; watch headers are skipped.
2870        assert_eq!(events.len(), 1, "only diagnostic line should produce event; got: {events:?}");
2871        assert_eq!(events[0].level, Level::Error);
2872        assert_eq!(events[0].fields["file"]["path"], "src/foo.ts");
2873    }
2874
2875    #[test]
2876    fn tsc_factory_declares_tool_version_range() {
2877        let f = TscBeholderFactory;
2878        let range = f.tool_version_range().expect("tsc factory must declare a version range");
2879        assert_eq!(range.min, Some("3.0.0"));
2880        assert!(range.max.is_none());
2881    }
2882
2883    // ─── EslintBeholderFactory tests ─────────────────────────────────────────
2884
2885    #[test]
2886    fn eslint_factory_matches_bare_eslint() {
2887        let f = EslintBeholderFactory;
2888        let argv: Vec<String> = vec!["eslint".into(), "src/".into()];
2889        assert!(f.matches(&argv));
2890    }
2891
2892    #[test]
2893    fn eslint_factory_matches_when_format_json_already_set() {
2894        let f = EslintBeholderFactory;
2895        let argv: Vec<String> = vec!["eslint".into(), "--format=json".into(), "src/".into()];
2896        assert!(f.matches(&argv), "must match when --format=json already present");
2897    }
2898
2899    #[test]
2900    fn eslint_factory_declines_non_json_format() {
2901        let f = EslintBeholderFactory;
2902        let argv: Vec<String> = vec!["eslint".into(), "--format=compact".into(), "src/".into()];
2903        assert!(!f.matches(&argv));
2904    }
2905
2906    #[test]
2907    fn eslint_factory_declines_version() {
2908        let f = EslintBeholderFactory;
2909        let argv: Vec<String> = vec!["eslint".into(), "--version".into()];
2910        assert!(!f.matches(&argv));
2911    }
2912
2913    #[test]
2914    fn eslint_factory_declines_env_info() {
2915        let f = EslintBeholderFactory;
2916        let argv: Vec<String> = vec!["eslint".into(), "--env-info".into()];
2917        assert!(!f.matches(&argv));
2918    }
2919
2920    #[test]
2921    fn eslint_factory_declines_non_eslint_command() {
2922        let f = EslintBeholderFactory;
2923        let argv: Vec<String> = vec!["tsc".into(), "--noEmit".into()];
2924        assert!(!f.matches(&argv));
2925    }
2926
2927    #[test]
2928    fn eslint_factory_rewriter_adds_format_json() {
2929        let registry = default_registry();
2930        let result = registry.attach("eslint src/", &BeholderSelect::Auto, false);
2931        assert!(result.beholder.is_some(), "eslint beholder must attach");
2932        assert!(
2933            result.argv.contains(&"--format=json".to_string()),
2934            "rewriter must inject --format=json; got: {:?}",
2935            result.argv
2936        );
2937        assert!(
2938            result.status.text.contains("rewrite="),
2939            "rewrite must be surfaced in status; got: {}",
2940            result.status.text
2941        );
2942    }
2943
2944    #[test]
2945    fn eslint_factory_no_rewrite_when_format_json_present() {
2946        let registry = default_registry();
2947        let result = registry.attach("eslint --format=json src/", &BeholderSelect::Auto, false);
2948        assert!(result.beholder.is_some(), "eslint beholder must attach");
2949        let count = result.argv.iter().filter(|a| a.as_str() == "--format=json").count();
2950        assert_eq!(count, 1, "--format=json must appear exactly once; got: {:?}", result.argv);
2951    }
2952
2953    #[test]
2954    fn eslint_beholder_parse_chunk_buffers_only() {
2955        let json = r#"[{"filePath":"/src/a.js","messages":[]}]"#;
2956        let mut b = EslintBeholder::default();
2957        let events = b.parse_chunk(&dummy_chunk(json.as_bytes()));
2958        assert!(events.is_empty(), "parse_chunk must not emit events; ESLint parses at EOF");
2959    }
2960
2961    #[test]
2962    fn eslint_beholder_on_done_parses_errors_and_warnings() {
2963        use crate::types::TaskRunId;
2964        let json = r#"[
2965            {
2966                "filePath": "/src/foo.ts",
2967                "messages": [
2968                    {
2969                        "ruleId": "no-unused-vars",
2970                        "severity": 2,
2971                        "message": "'x' is defined but never used.",
2972                        "line": 10,
2973                        "column": 5
2974                    },
2975                    {
2976                        "ruleId": "no-console",
2977                        "severity": 1,
2978                        "message": "Unexpected console statement.",
2979                        "line": 20,
2980                        "column": 1
2981                    }
2982                ],
2983                "errorCount": 1,
2984                "warningCount": 1
2985            }
2986        ]"#;
2987        let mut b = EslintBeholder::default();
2988        b.parse_chunk(&dummy_chunk(json.as_bytes()));
2989        let run_id = TaskRunId::new();
2990        let events = b.on_done(&run_id, 0);
2991        assert_eq!(events.len(), 2);
2992        let err = &events[0];
2993        assert_eq!(err.level, Level::Error);
2994        assert_eq!(err.target, "eslint");
2995        assert_eq!(err.msg, "'x' is defined but never used.");
2996        assert_eq!(err.fields["error"]["code"], "no-unused-vars");
2997        assert_eq!(err.fields["file"]["path"], "/src/foo.ts");
2998        assert_eq!(err.fields["file"]["line"], 10u32);
2999        assert_eq!(err.fields["file"]["col"], 5u32);
3000        let warn = &events[1];
3001        assert_eq!(warn.level, Level::Warn);
3002        assert_eq!(warn.fields["error"]["code"], "no-console");
3003    }
3004
3005    #[test]
3006    fn eslint_beholder_on_done_empty_output() {
3007        use crate::types::TaskRunId;
3008        let mut b = EslintBeholder::default();
3009        let events = b.on_done(&TaskRunId::new(), 0);
3010        assert!(events.is_empty(), "empty buffer must produce no events");
3011    }
3012
3013    #[test]
3014    fn eslint_beholder_on_done_invalid_json_flags_unknown_format() {
3015        use crate::types::TaskRunId;
3016        let bad = b"not json at all";
3017        let mut b = EslintBeholder::default();
3018        b.parse_chunk(&dummy_chunk(bad));
3019        let events = b.on_done(&TaskRunId::new(), 0);
3020        assert!(events.is_empty(), "bad JSON must not produce events");
3021        assert!(
3022            b.unknown_format_reason().is_some(),
3023            "bad JSON must set unknown_format_reason"
3024        );
3025    }
3026
3027    #[test]
3028    fn eslint_beholder_on_done_chunks_split_across_multiple_calls() {
3029        use crate::types::TaskRunId;
3030        let json = r#"[{"filePath":"/a.ts","messages":[{"ruleId":"eqeqeq","severity":2,"message":"Use ===.","line":3,"column":7}],"errorCount":1,"warningCount":0}]"#;
3031        let mid = json.len() / 2;
3032        let mut b = EslintBeholder::default();
3033        b.parse_chunk(&dummy_chunk(json[..mid].as_bytes()));
3034        b.parse_chunk(&dummy_chunk(json[mid..].as_bytes()));
3035        let events = b.on_done(&TaskRunId::new(), 0);
3036        assert_eq!(events.len(), 1, "split-chunk delivery must still parse correctly");
3037        assert_eq!(events[0].fields["error"]["code"], "eqeqeq");
3038    }
3039
3040    #[test]
3041    fn eslint_factory_declares_tool_version_range() {
3042        let f = EslintBeholderFactory;
3043        let range = f.tool_version_range().expect("eslint factory must declare a version range");
3044        assert_eq!(range.min, Some("8.0.0"));
3045        assert!(range.max.is_none());
3046    }
3047
3048    // ─── BiomeBeholderFactory tests ───────────────────────────────────────────
3049
3050    #[test]
3051    fn biome_factory_matches_check() {
3052        let f = BiomeBeholderFactory;
3053        let argv: Vec<String> = vec!["biome".into(), "check".into(), "src/".into()];
3054        assert!(f.matches(&argv));
3055    }
3056
3057    #[test]
3058    fn biome_factory_matches_lint() {
3059        let f = BiomeBeholderFactory;
3060        let argv: Vec<String> = vec!["biome".into(), "lint".into(), "src/".into()];
3061        assert!(f.matches(&argv));
3062    }
3063
3064    #[test]
3065    fn biome_factory_matches_ci() {
3066        let f = BiomeBeholderFactory;
3067        let argv: Vec<String> = vec!["biome".into(), "ci".into(), "src/".into()];
3068        assert!(f.matches(&argv));
3069    }
3070
3071    #[test]
3072    fn biome_factory_declines_format_subcommand() {
3073        let f = BiomeBeholderFactory;
3074        let argv: Vec<String> = vec!["biome".into(), "format".into(), "src/".into()];
3075        assert!(!f.matches(&argv));
3076    }
3077
3078    #[test]
3079    fn biome_factory_declines_version() {
3080        let f = BiomeBeholderFactory;
3081        let argv: Vec<String> = vec!["biome".into(), "--version".into()];
3082        assert!(!f.matches(&argv));
3083    }
3084
3085    #[test]
3086    fn biome_factory_declines_non_json_reporter() {
3087        let f = BiomeBeholderFactory;
3088        let argv: Vec<String> = vec!["biome".into(), "check".into(), "--reporter=github".into()];
3089        assert!(!f.matches(&argv));
3090    }
3091
3092    #[test]
3093    fn biome_factory_matches_when_reporter_json_present() {
3094        let f = BiomeBeholderFactory;
3095        let argv: Vec<String> = vec!["biome".into(), "check".into(), "--reporter=json".into()];
3096        assert!(f.matches(&argv), "must match when --reporter=json already set");
3097    }
3098
3099    #[test]
3100    fn biome_factory_declines_non_biome_command() {
3101        let f = BiomeBeholderFactory;
3102        let argv: Vec<String> = vec!["eslint".into(), "src/".into()];
3103        assert!(!f.matches(&argv));
3104    }
3105
3106    #[test]
3107    fn biome_factory_rewriter_adds_reporter_json() {
3108        let registry = default_registry();
3109        let result = registry.attach("biome check src/", &BeholderSelect::Auto, false);
3110        assert!(result.beholder.is_some(), "biome beholder must attach");
3111        assert!(
3112            result.argv.contains(&"--reporter=json".to_string()),
3113            "rewriter must inject --reporter=json; got: {:?}",
3114            result.argv
3115        );
3116        assert!(
3117            result.status.text.contains("rewrite="),
3118            "rewrite must be surfaced in status; got: {}",
3119            result.status.text
3120        );
3121    }
3122
3123    #[test]
3124    fn biome_factory_no_rewrite_when_reporter_json_present() {
3125        let registry = default_registry();
3126        let result = registry.attach("biome lint --reporter=json src/", &BeholderSelect::Auto, false);
3127        assert!(result.beholder.is_some(), "biome beholder must attach");
3128        let count = result.argv.iter().filter(|a| a.as_str() == "--reporter=json").count();
3129        assert_eq!(count, 1, "--reporter=json must appear exactly once; got: {:?}", result.argv);
3130    }
3131
3132    #[test]
3133    fn biome_beholder_parse_chunk_buffers_only() {
3134        let json = r#"{"diagnostics":[],"summary":{}}"#;
3135        let mut b = BiomeBeholder::default();
3136        let events = b.parse_chunk(&dummy_chunk(json.as_bytes()));
3137        assert!(events.is_empty(), "parse_chunk must not emit events; Biome parses at EOF");
3138    }
3139
3140    #[test]
3141    fn biome_beholder_on_done_parses_error() {
3142        use crate::types::TaskRunId;
3143        let json = r#"{
3144            "diagnostics": [
3145                {
3146                    "category": "lint/suspicious/noDoubleEquals",
3147                    "severity": "error",
3148                    "description": "Use === instead of ==",
3149                    "location": {
3150                        "path": {"file": "src/foo.ts"}
3151                    }
3152                }
3153            ],
3154            "summary": {"changed": 0, "unchanged": 1, "errors": 1}
3155        }"#;
3156        let mut b = BiomeBeholder::default();
3157        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3158        let events = b.on_done(&TaskRunId::new(), 0);
3159        assert_eq!(events.len(), 1);
3160        let ev = &events[0];
3161        assert_eq!(ev.level, Level::Error);
3162        assert_eq!(ev.target, "biome");
3163        assert_eq!(ev.msg, "Use === instead of ==");
3164        assert_eq!(ev.fields["error"]["code"], "lint/suspicious/noDoubleEquals");
3165        assert_eq!(ev.fields["file"]["path"], "src/foo.ts");
3166    }
3167
3168    #[test]
3169    fn biome_beholder_on_done_parses_warning() {
3170        use crate::types::TaskRunId;
3171        let json = r#"{
3172            "diagnostics": [
3173                {
3174                    "category": "lint/style/useConst",
3175                    "severity": "warning",
3176                    "description": "Prefer const over let.",
3177                    "location": {"path": {"file": "src/bar.ts"}}
3178                }
3179            ],
3180            "summary": {}
3181        }"#;
3182        let mut b = BiomeBeholder::default();
3183        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3184        let events = b.on_done(&TaskRunId::new(), 0);
3185        assert_eq!(events.len(), 1);
3186        assert_eq!(events[0].level, Level::Warn);
3187        assert_eq!(events[0].fields["file"]["path"], "src/bar.ts");
3188    }
3189
3190    #[test]
3191    fn biome_beholder_on_done_empty_diagnostics() {
3192        use crate::types::TaskRunId;
3193        let json = r#"{"diagnostics":[],"summary":{"changed":0,"unchanged":5,"errors":0}}"#;
3194        let mut b = BiomeBeholder::default();
3195        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3196        let events = b.on_done(&TaskRunId::new(), 0);
3197        assert!(events.is_empty(), "zero diagnostics must produce no events");
3198    }
3199
3200    #[test]
3201    fn biome_beholder_on_done_invalid_json_flags_unknown_format() {
3202        use crate::types::TaskRunId;
3203        let mut b = BiomeBeholder::default();
3204        b.parse_chunk(&dummy_chunk(b"not json"));
3205        let events = b.on_done(&TaskRunId::new(), 0);
3206        assert!(events.is_empty());
3207        assert!(b.unknown_format_reason().is_some());
3208    }
3209
3210    #[test]
3211    fn biome_factory_declares_tool_version_range() {
3212        let f = BiomeBeholderFactory;
3213        let range = f.tool_version_range().expect("biome factory must declare a version range");
3214        assert_eq!(range.min, Some("1.0.0"));
3215        assert!(range.max.is_none());
3216    }
3217
3218    // ─── VitestBeholderFactory tests ──────────────────────────────────────────
3219
3220    #[test]
3221    fn vitest_factory_matches_bare_vitest() {
3222        let f = VitestBeholderFactory;
3223        let argv: Vec<String> = vec!["vitest".into(), "--run".into()];
3224        assert!(f.matches(&argv));
3225    }
3226
3227    #[test]
3228    fn vitest_factory_matches_with_reporter_json_already_set() {
3229        let f = VitestBeholderFactory;
3230        let argv: Vec<String> = vec!["vitest".into(), "--reporter=json".into()];
3231        assert!(f.matches(&argv), "must match when --reporter=json already present");
3232    }
3233
3234    #[test]
3235    fn vitest_factory_declines_non_json_reporter_eq() {
3236        let f = VitestBeholderFactory;
3237        let argv: Vec<String> = vec!["vitest".into(), "--reporter=verbose".into()];
3238        assert!(!f.matches(&argv));
3239    }
3240
3241    #[test]
3242    fn vitest_factory_declines_non_json_reporter_space() {
3243        let f = VitestBeholderFactory;
3244        let argv: Vec<String> = vec!["vitest".into(), "--reporter".into(), "verbose".into()];
3245        assert!(!f.matches(&argv));
3246    }
3247
3248    #[test]
3249    fn vitest_factory_declines_version() {
3250        let f = VitestBeholderFactory;
3251        let argv: Vec<String> = vec!["vitest".into(), "--version".into()];
3252        assert!(!f.matches(&argv));
3253    }
3254
3255    #[test]
3256    fn vitest_factory_declines_non_vitest_command() {
3257        let f = VitestBeholderFactory;
3258        let argv: Vec<String> = vec!["jest".into(), "--run".into()];
3259        assert!(!f.matches(&argv));
3260    }
3261
3262    #[test]
3263    fn vitest_factory_rewriter_adds_reporter_json() {
3264        let registry = default_registry();
3265        let result = registry.attach("vitest --run", &BeholderSelect::Auto, false);
3266        assert!(result.beholder.is_some(), "vitest beholder must attach");
3267        assert!(
3268            result.argv.contains(&"--reporter=json".to_string()),
3269            "rewriter must inject --reporter=json; got: {:?}",
3270            result.argv
3271        );
3272        assert!(
3273            result.status.text.contains("rewrite="),
3274            "rewrite must be surfaced in status; got: {}",
3275            result.status.text
3276        );
3277    }
3278
3279    #[test]
3280    fn vitest_factory_no_rewrite_when_reporter_json_present() {
3281        let registry = default_registry();
3282        let result = registry.attach("vitest --reporter=json --run", &BeholderSelect::Auto, false);
3283        assert!(result.beholder.is_some());
3284        let count = result.argv.iter().filter(|a| a.as_str() == "--reporter=json").count();
3285        assert_eq!(count, 1, "--reporter=json must appear exactly once; got: {:?}", result.argv);
3286    }
3287
3288    #[test]
3289    fn vitest_factory_declares_tool_version_range() {
3290        let f = VitestBeholderFactory;
3291        let range = f.tool_version_range().expect("vitest factory must declare a version range");
3292        assert_eq!(range.min, Some("1.0.0"));
3293        assert!(range.max.is_none());
3294    }
3295
3296    // ─── JestBeholderFactory tests ────────────────────────────────────────────
3297
3298    #[test]
3299    fn jest_factory_matches_bare_jest() {
3300        let f = JestBeholderFactory;
3301        let argv: Vec<String> = vec!["jest".into()];
3302        assert!(f.matches(&argv));
3303    }
3304
3305    #[test]
3306    fn jest_factory_matches_jest_with_path() {
3307        let f = JestBeholderFactory;
3308        let argv: Vec<String> = vec!["jest".into(), "src/foo.test.ts".into()];
3309        assert!(f.matches(&argv));
3310    }
3311
3312    #[test]
3313    fn jest_factory_matches_when_json_already_set() {
3314        let f = JestBeholderFactory;
3315        let argv: Vec<String> = vec!["jest".into(), "--json".into()];
3316        assert!(f.matches(&argv), "must match when --json already present");
3317    }
3318
3319    #[test]
3320    fn jest_factory_declines_version() {
3321        let f = JestBeholderFactory;
3322        let argv: Vec<String> = vec!["jest".into(), "--version".into()];
3323        assert!(!f.matches(&argv));
3324    }
3325
3326    #[test]
3327    fn jest_factory_declines_output_file() {
3328        let f = JestBeholderFactory;
3329        let argv: Vec<String> = vec!["jest".into(), "--outputFile=results.json".into()];
3330        assert!(!f.matches(&argv), "must decline when --outputFile is set (JSON goes to file)");
3331    }
3332
3333    #[test]
3334    fn jest_factory_declines_non_jest_command() {
3335        let f = JestBeholderFactory;
3336        let argv: Vec<String> = vec!["vitest".into()];
3337        assert!(!f.matches(&argv));
3338    }
3339
3340    #[test]
3341    fn jest_factory_rewriter_adds_json_flag() {
3342        let registry = default_registry();
3343        let result = registry.attach("jest src/", &BeholderSelect::Auto, false);
3344        assert!(result.beholder.is_some(), "jest beholder must attach");
3345        assert!(
3346            result.argv.contains(&"--json".to_string()),
3347            "rewriter must inject --json; got: {:?}",
3348            result.argv
3349        );
3350        assert!(
3351            result.status.text.contains("rewrite="),
3352            "rewrite must be surfaced in status; got: {}",
3353            result.status.text
3354        );
3355    }
3356
3357    #[test]
3358    fn jest_factory_no_rewrite_when_json_present() {
3359        let registry = default_registry();
3360        let result = registry.attach("jest --json src/", &BeholderSelect::Auto, false);
3361        assert!(result.beholder.is_some());
3362        let count = result.argv.iter().filter(|a| a.as_str() == "--json").count();
3363        assert_eq!(count, 1, "--json must appear exactly once; got: {:?}", result.argv);
3364    }
3365
3366    #[test]
3367    fn jest_factory_declares_tool_version_range() {
3368        let f = JestBeholderFactory;
3369        let range = f.tool_version_range().expect("jest factory must declare a version range");
3370        assert_eq!(range.min, Some("27.0.0"));
3371        assert!(range.max.is_none());
3372    }
3373
3374    // ─── BunTestBeholderFactory tests ─────────────────────────────────────────
3375
3376    #[test]
3377    fn bun_test_factory_matches_bun_test() {
3378        let f = BunTestBeholderFactory;
3379        let argv: Vec<String> = vec!["bun".into(), "test".into()];
3380        assert!(f.matches(&argv));
3381    }
3382
3383    #[test]
3384    fn bun_test_factory_matches_bun_test_with_path() {
3385        let f = BunTestBeholderFactory;
3386        let argv: Vec<String> = vec!["bun".into(), "test".into(), "src/".into()];
3387        assert!(f.matches(&argv));
3388    }
3389
3390    #[test]
3391    fn bun_test_factory_matches_when_reporter_json_already_set() {
3392        let f = BunTestBeholderFactory;
3393        let argv: Vec<String> = vec!["bun".into(), "test".into(), "--reporter=json".into()];
3394        assert!(f.matches(&argv));
3395    }
3396
3397    #[test]
3398    fn bun_test_factory_declines_bare_bun() {
3399        let f = BunTestBeholderFactory;
3400        let argv: Vec<String> = vec!["bun".into(), "run".into(), "build.ts".into()];
3401        assert!(!f.matches(&argv));
3402    }
3403
3404    #[test]
3405    fn bun_test_factory_declines_non_json_reporter() {
3406        let f = BunTestBeholderFactory;
3407        let argv: Vec<String> = vec!["bun".into(), "test".into(), "--reporter=junit".into()];
3408        assert!(!f.matches(&argv));
3409    }
3410
3411    #[test]
3412    fn bun_test_factory_declines_version() {
3413        let f = BunTestBeholderFactory;
3414        let argv: Vec<String> = vec!["bun".into(), "test".into(), "--version".into()];
3415        assert!(!f.matches(&argv));
3416    }
3417
3418    #[test]
3419    fn bun_test_factory_rewriter_adds_reporter_json() {
3420        let registry = default_registry();
3421        let result = registry.attach("bun test", &BeholderSelect::Auto, false);
3422        assert!(result.beholder.is_some(), "bun-test beholder must attach");
3423        assert!(
3424            result.argv.contains(&"--reporter=json".to_string()),
3425            "rewriter must inject --reporter=json; got: {:?}",
3426            result.argv
3427        );
3428    }
3429
3430    #[test]
3431    fn bun_test_factory_declares_tool_version_range() {
3432        let f = BunTestBeholderFactory;
3433        let range = f.tool_version_range().expect("bun-test factory must declare a version range");
3434        assert_eq!(range.min, Some("1.0.0"));
3435        assert!(range.max.is_none());
3436    }
3437
3438    // ─── JsTestBeholder on_done tests ─────────────────────────────────────────
3439
3440    fn js_test_report_json(success: bool, passed: u32, failed: u32) -> String {
3441        format!(
3442            r#"{{
3443                "success": {success},
3444                "numTotalTests": {total},
3445                "numPassedTests": {passed},
3446                "numFailedTests": {failed},
3447                "numPendingTests": 0,
3448                "testResults": []
3449            }}"#,
3450            total = passed + failed,
3451        )
3452    }
3453
3454    #[test]
3455    fn js_test_beholder_on_done_success_summary() {
3456        use crate::types::TaskRunId;
3457        let json = js_test_report_json(true, 5, 0);
3458        let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3459        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3460        let events = b.on_done(&TaskRunId::new(), 0);
3461        assert_eq!(events.len(), 1, "success run must produce one summary event");
3462        let ev = &events[0];
3463        assert_eq!(ev.target, "vitest");
3464        assert_eq!(ev.level, Level::Info);
3465        assert_eq!(ev.msg, "5 passed");
3466        assert_eq!(ev.fields["build"]["success"], true);
3467        assert_eq!(ev.fields["test"]["passed"], 5u32);
3468        assert_eq!(ev.fields["test"]["failed"], 0u32);
3469    }
3470
3471    #[test]
3472    fn js_test_beholder_on_done_failure_summary() {
3473        use crate::types::TaskRunId;
3474        let json = js_test_report_json(false, 3, 2);
3475        let mut b = JsTestBeholder::new("jest", "27.0", jest_adjust_argv);
3476        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3477        let events = b.on_done(&TaskRunId::new(), 0);
3478        assert_eq!(events.len(), 1, "no assertion results → only summary event");
3479        let ev = &events[0];
3480        assert_eq!(ev.target, "jest");
3481        assert_eq!(ev.level, Level::Error);
3482        assert_eq!(ev.msg, "2 failed, 3 passed");
3483    }
3484
3485    #[test]
3486    fn js_test_beholder_on_done_emits_failed_assertion_events() {
3487        use crate::types::TaskRunId;
3488        let json = r#"{
3489            "success": false,
3490            "numTotalTests": 2,
3491            "numPassedTests": 1,
3492            "numFailedTests": 1,
3493            "numPendingTests": 0,
3494            "testResults": [
3495                {
3496                    "testFilePath": "src/foo.test.ts",
3497                    "assertionResults": [
3498                        {
3499                            "fullName": "suite > passes",
3500                            "status": "passed",
3501                            "failureMessages": []
3502                        },
3503                        {
3504                            "fullName": "suite > fails",
3505                            "status": "failed",
3506                            "failureMessages": ["Error: expected 1 to equal 2\n  at foo (src/foo.test.ts:10)"]
3507                        }
3508                    ]
3509                }
3510            ]
3511        }"#;
3512        let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3513        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3514        let run_id = TaskRunId::new();
3515        let events = b.on_done(&run_id, 0);
3516        // summary + one failure event (passed assertion is skipped)
3517        assert_eq!(events.len(), 2, "expected summary + one failure event; got: {events:?}");
3518        let failure = &events[1];
3519        assert_eq!(failure.level, Level::Error);
3520        assert_eq!(failure.target, "vitest::test");
3521        assert_eq!(failure.msg, "Error: expected 1 to equal 2");
3522        assert_eq!(failure.fields["test"]["name"], "suite > fails");
3523        assert_eq!(failure.fields["file"]["path"], "src/foo.test.ts");
3524    }
3525
3526    #[test]
3527    fn js_test_beholder_on_done_multiple_suites_and_failures() {
3528        use crate::types::TaskRunId;
3529        let json = r#"{
3530            "success": false,
3531            "numTotalTests": 4,
3532            "numPassedTests": 2,
3533            "numFailedTests": 2,
3534            "numPendingTests": 0,
3535            "testResults": [
3536                {
3537                    "testFilePath": "src/a.test.ts",
3538                    "assertionResults": [
3539                        { "fullName": "A passes", "status": "passed", "failureMessages": [] },
3540                        { "fullName": "A fails", "status": "failed", "failureMessages": ["err A"] }
3541                    ]
3542                },
3543                {
3544                    "testFilePath": "src/b.test.ts",
3545                    "assertionResults": [
3546                        { "fullName": "B passes", "status": "passed", "failureMessages": [] },
3547                        { "fullName": "B fails", "status": "failed", "failureMessages": ["err B"] }
3548                    ]
3549                }
3550            ]
3551        }"#;
3552        let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3553        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3554        let events = b.on_done(&TaskRunId::new(), 0);
3555        // summary + 2 failure events
3556        assert_eq!(events.len(), 3);
3557        assert_eq!(events[1].fields["file"]["path"], "src/a.test.ts");
3558        assert_eq!(events[2].fields["file"]["path"], "src/b.test.ts");
3559    }
3560
3561    #[test]
3562    fn js_test_beholder_on_done_empty_buffer_returns_no_events() {
3563        use crate::types::TaskRunId;
3564        let b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3565        // Do not call parse_chunk — buffer is empty.
3566        let mut b = b;
3567        let events = b.on_done(&TaskRunId::new(), 0);
3568        assert!(events.is_empty());
3569    }
3570
3571    #[test]
3572    fn js_test_beholder_on_done_invalid_json_flags_unknown_format() {
3573        use crate::types::TaskRunId;
3574        let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3575        b.parse_chunk(&dummy_chunk(b"not json"));
3576        let events = b.on_done(&TaskRunId::new(), 0);
3577        assert!(events.is_empty());
3578        assert!(b.unknown_format_reason().is_some());
3579    }
3580
3581    #[test]
3582    fn js_test_beholder_on_done_chunks_split_across_calls() {
3583        use crate::types::TaskRunId;
3584        let json = r#"{"success":true,"numTotalTests":1,"numPassedTests":1,"numFailedTests":0,"numPendingTests":0,"testResults":[]}"#;
3585        let mid = json.len() / 2;
3586        let mut b = JsTestBeholder::new("jest", "27.0", jest_adjust_argv);
3587        b.parse_chunk(&dummy_chunk(json[..mid].as_bytes()));
3588        b.parse_chunk(&dummy_chunk(json[mid..].as_bytes()));
3589        let events = b.on_done(&TaskRunId::new(), 0);
3590        assert_eq!(events.len(), 1, "split chunks must still parse correctly");
3591        assert_eq!(events[0].level, Level::Info);
3592    }
3593
3594    #[test]
3595    fn js_test_beholder_failure_msg_uses_first_line_only() {
3596        use crate::types::TaskRunId;
3597        let json = r#"{
3598            "success": false,
3599            "numTotalTests": 1, "numPassedTests": 0, "numFailedTests": 1, "numPendingTests": 0,
3600            "testResults": [{
3601                "testFilePath": "x.test.ts",
3602                "assertionResults": [{
3603                    "fullName": "fails",
3604                    "status": "failed",
3605                    "failureMessages": ["Error: oops\n  at Object.<anonymous> (x.test.ts:5:5)\n  at ...]"]
3606                }]
3607            }]
3608        }"#;
3609        let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3610        b.parse_chunk(&dummy_chunk(json.as_bytes()));
3611        let events = b.on_done(&TaskRunId::new(), 0);
3612        assert_eq!(events.len(), 2);
3613        // Failure event msg must be the first line only, not the full stack trace.
3614        assert_eq!(events[1].msg, "Error: oops");
3615    }
3616
3617    // ─── PytestBeholderFactory tests ──────────────────────────────────────────
3618
3619    fn args(s: &str) -> Vec<String> { s.split_whitespace().map(str::to_owned).collect() }
3620
3621    #[test]
3622    fn pytest_factory_matches_direct() {
3623        let f = PytestBeholderFactory;
3624        assert!(f.matches(&args("pytest")));
3625        assert!(f.matches(&args("pytest tests/")));
3626        assert!(f.matches(&args("py.test -v")));
3627    }
3628
3629    #[test]
3630    fn pytest_factory_matches_python_m_pytest() {
3631        let f = PytestBeholderFactory;
3632        assert!(f.matches(&args("python -m pytest")));
3633        assert!(f.matches(&args("python3 -m pytest tests/")));
3634    }
3635
3636    #[test]
3637    fn pytest_factory_declines_version_and_help() {
3638        let f = PytestBeholderFactory;
3639        assert!(!f.matches(&args("pytest --version")));
3640        assert!(!f.matches(&args("pytest -V")));
3641        assert!(!f.matches(&args("pytest --help")));
3642        assert!(!f.matches(&args("pytest -h")));
3643    }
3644
3645    #[test]
3646    fn pytest_factory_declines_collect_only() {
3647        let f = PytestBeholderFactory;
3648        assert!(!f.matches(&args("pytest --collect-only")));
3649        assert!(!f.matches(&args("pytest --co")));
3650    }
3651
3652    #[test]
3653    fn pytest_factory_declines_non_pytest() {
3654        let f = PytestBeholderFactory;
3655        assert!(!f.matches(&args("cargo test")));
3656        assert!(!f.matches(&args("python script.py")));
3657        assert!(!f.matches(&args("python -m flask run")));
3658    }
3659
3660    #[test]
3661    fn pytest_factory_mode_is_parser() {
3662        assert!(matches!(PytestBeholderFactory.mode(), BeholderMode::Parser));
3663    }
3664
3665    // ─── PytestBeholder parse tests ───────────────────────────────────────────
3666
3667    fn pytest_output_chunk(lines: &str, seq: u32) -> OutputChunk {
3668        use crate::types::TaskRunId;
3669        OutputChunk {
3670            run_id: TaskRunId::new(),
3671            seq,
3672            offset_ms: 0,
3673            stream: crate::types::Stream::Stdout,
3674            bytes: lines.as_bytes().to_vec(),
3675        }
3676    }
3677
3678    #[test]
3679    fn pytest_beholder_parses_failure_lines() {
3680        let mut b = PytestBeholder::default();
3681        let chunk = pytest_output_chunk(
3682            "FAILED tests/test_foo.py::test_bar - AssertionError: assert 1 == 2\n\
3683             FAILED tests/test_baz.py::test_qux - ZeroDivisionError: division by zero\n",
3684            1,
3685        );
3686        let events = b.parse_chunk(&chunk);
3687        assert_eq!(events.len(), 2);
3688        assert_eq!(events[0].level, Level::Error);
3689        assert_eq!(events[0].target, "pytest::test");
3690        assert_eq!(events[0].msg, "AssertionError: assert 1 == 2");
3691        assert_eq!(events[0].fields["test"]["name"], "test_bar");
3692        assert_eq!(events[0].fields["file"]["path"], "tests/test_foo.py");
3693        assert_eq!(events[1].msg, "ZeroDivisionError: division by zero");
3694    }
3695
3696    #[test]
3697    fn pytest_beholder_parses_error_lines() {
3698        let mut b = PytestBeholder::default();
3699        let chunk = pytest_output_chunk(
3700            "ERROR tests/test_broken.py - ImportError: No module named 'foo'\n",
3701            1,
3702        );
3703        let events = b.parse_chunk(&chunk);
3704        assert_eq!(events.len(), 1);
3705        assert_eq!(events[0].msg, "ImportError: No module named 'foo'");
3706        assert_eq!(events[0].fields["file"]["path"], "tests/test_broken.py");
3707    }
3708
3709    #[test]
3710    fn pytest_beholder_failure_without_reason() {
3711        let mut b = PytestBeholder::default();
3712        let chunk = pytest_output_chunk("FAILED tests/test_foo.py::test_bar\n", 1);
3713        let events = b.parse_chunk(&chunk);
3714        assert_eq!(events.len(), 1);
3715        assert_eq!(events[0].msg, "FAILED tests/test_foo.py::test_bar");
3716    }
3717
3718    #[test]
3719    fn pytest_beholder_ignores_lines_without_py() {
3720        let mut b = PytestBeholder::default();
3721        // These should not produce events — no `.py` in the nodeid.
3722        let chunk = pytest_output_chunk(
3723            "FAILED some_other_thing\nERROR not_a_module\n",
3724            1,
3725        );
3726        let events = b.parse_chunk(&chunk);
3727        assert!(events.is_empty());
3728    }
3729
3730    #[test]
3731    fn pytest_beholder_on_done_success_summary() {
3732        use crate::types::TaskRunId;
3733        let mut b = PytestBeholder::default();
3734        let chunk = pytest_output_chunk(
3735            "collected 5 items\n\
3736             ========================= 5 passed in 0.85s ==========================\n",
3737            1,
3738        );
3739        b.parse_chunk(&chunk);
3740        let events = b.on_done(&TaskRunId::new(), 0);
3741        assert_eq!(events.len(), 1);
3742        let ev = &events[0];
3743        assert_eq!(ev.level, Level::Info);
3744        assert_eq!(ev.target, "pytest");
3745        assert_eq!(ev.msg, "5 passed");
3746        assert_eq!(ev.fields["test"]["passed"], 5u32);
3747        assert_eq!(ev.fields["test"]["failed"], 0u32);
3748        assert_eq!(ev.fields["build"]["success"], true);
3749    }
3750
3751    #[test]
3752    fn pytest_beholder_on_done_failure_summary() {
3753        use crate::types::TaskRunId;
3754        let mut b = PytestBeholder::default();
3755        let chunk = pytest_output_chunk(
3756            "FAILED tests/a.py::t1 - err\n\
3757             ======= 1 failed, 4 passed in 1.23s =======\n",
3758            1,
3759        );
3760        b.parse_chunk(&chunk);
3761        let events = b.on_done(&TaskRunId::new(), 0);
3762        assert_eq!(events.len(), 1);
3763        let ev = &events[0];
3764        assert_eq!(ev.level, Level::Error);
3765        assert_eq!(ev.msg, "1 failed, 4 passed");
3766        assert_eq!(ev.fields["test"]["failed"], 1u32);
3767        assert_eq!(ev.fields["test"]["passed"], 4u32);
3768        assert_eq!(ev.fields["build"]["success"], false);
3769    }
3770
3771    #[test]
3772    fn pytest_beholder_on_done_mixed_summary() {
3773        use crate::types::TaskRunId;
3774        let mut b = PytestBeholder::default();
3775        let chunk = pytest_output_chunk(
3776            "====== 2 failed, 1 error, 5 passed, 1 skipped in 3.14s ======\n",
3777            1,
3778        );
3779        b.parse_chunk(&chunk);
3780        let events = b.on_done(&TaskRunId::new(), 0);
3781        assert_eq!(events.len(), 1);
3782        let ev = &events[0];
3783        assert_eq!(ev.level, Level::Error);
3784        assert_eq!(ev.msg, "2 failed, 1 error, 5 passed, 1 skipped");
3785        assert_eq!(ev.fields["test"]["failed"], 2u32);
3786        assert_eq!(ev.fields["test"]["errors"], 1u32);
3787        assert_eq!(ev.fields["test"]["passed"], 5u32);
3788        assert_eq!(ev.fields["test"]["skipped"], 1u32);
3789    }
3790
3791    #[test]
3792    fn pytest_beholder_on_done_no_summary_returns_empty() {
3793        use crate::types::TaskRunId;
3794        let mut b = PytestBeholder::default();
3795        let events = b.on_done(&TaskRunId::new(), 0);
3796        assert!(events.is_empty());
3797    }
3798
3799    #[test]
3800    fn pytest_beholder_chunks_split_across_calls() {
3801        let line = "FAILED tests/foo.py::bar - AssertionError\n";
3802        let mid = line.len() / 2;
3803        let mut b = PytestBeholder::default();
3804        b.parse_chunk(&pytest_output_chunk(&line[..mid], 1));
3805        let ev1 = b.parse_chunk(&pytest_output_chunk(&line[mid..], 2));
3806        // The complete FAILED line should be emitted from the second chunk.
3807        assert_eq!(ev1.len(), 1);
3808        assert_eq!(ev1[0].msg, "AssertionError");
3809    }
3810
3811    // ─── parse_pytest_summary unit tests ──────────────────────────────────────
3812
3813    #[test]
3814    fn parse_pytest_summary_all_passed() {
3815        let s = parse_pytest_summary("5 passed in 0.85s").unwrap();
3816        assert_eq!(s.passed, 5);
3817        assert_eq!(s.failed, 0);
3818    }
3819
3820    #[test]
3821    fn parse_pytest_summary_mixed() {
3822        let s = parse_pytest_summary("2 failed, 5 passed, 1 skipped in 3.14s").unwrap();
3823        assert_eq!(s.failed, 2);
3824        assert_eq!(s.passed, 5);
3825        assert_eq!(s.skipped, 1);
3826        assert_eq!(s.errors, 0);
3827    }
3828
3829    #[test]
3830    fn parse_pytest_summary_with_errors() {
3831        let s = parse_pytest_summary("1 error, 3 passed in 1.0s").unwrap();
3832        assert_eq!(s.errors, 1);
3833        assert_eq!(s.passed, 3);
3834    }
3835
3836    #[test]
3837    fn parse_pytest_summary_rejects_garbage() {
3838        assert!(parse_pytest_summary("not a summary").is_err());
3839        assert!(parse_pytest_summary("").is_err());
3840    }
3841
3842    // ─── ViteBuildBeholderFactory tests ───────────────────────────────────────
3843
3844    #[test]
3845    fn vite_build_factory_matches_bare() {
3846        let f = ViteBuildBeholderFactory;
3847        assert!(f.matches(&args("vite build")));
3848        assert!(f.matches(&args("vite build --outDir dist")));
3849    }
3850
3851    #[test]
3852    fn vite_build_factory_declines_no_build_subcommand() {
3853        let f = ViteBuildBeholderFactory;
3854        assert!(!f.matches(&args("vite")));
3855        assert!(!f.matches(&args("vite preview")));
3856        assert!(!f.matches(&args("vite dev")));
3857    }
3858
3859    #[test]
3860    fn vite_build_factory_declines_help_and_version() {
3861        let f = ViteBuildBeholderFactory;
3862        assert!(!f.matches(&args("vite build --help")));
3863        assert!(!f.matches(&args("vite build -h")));
3864        assert!(!f.matches(&args("vite build --version")));
3865    }
3866
3867    #[test]
3868    fn vite_build_factory_declines_non_vite() {
3869        let f = ViteBuildBeholderFactory;
3870        assert!(!f.matches(&args("cargo build")));
3871        assert!(!f.matches(&args("tsc")));
3872    }
3873
3874    #[test]
3875    fn vite_build_factory_mode_is_parser() {
3876        assert!(matches!(ViteBuildBeholderFactory.mode(), BeholderMode::Parser));
3877    }
3878
3879    // ─── ViteBuildBeholder parse tests ────────────────────────────────────────
3880
3881    fn vite_chunk(lines: &str, seq: u32) -> OutputChunk {
3882        use crate::types::TaskRunId;
3883        OutputChunk {
3884            run_id: TaskRunId::new(),
3885            seq,
3886            offset_ms: 100,
3887            stream: crate::types::Stream::Stdout,
3888            bytes: lines.as_bytes().to_vec(),
3889        }
3890    }
3891
3892    #[test]
3893    fn vite_beholder_parses_warning_line() {
3894        let mut b = ViteBuildBeholder::default();
3895        let chunk = vite_chunk(
3896            "(!) Some chunks are larger than 500 kB after minification.\n",
3897            1,
3898        );
3899        let events = b.parse_chunk(&chunk);
3900        assert_eq!(events.len(), 1);
3901        assert_eq!(events[0].level, Level::Warn);
3902        assert_eq!(events[0].target, "vite-build::warning");
3903        assert_eq!(events[0].msg, "Some chunks are larger than 500 kB after minification.");
3904        assert_eq!(events[0].fields["build"]["warning"], true);
3905    }
3906
3907    #[test]
3908    fn vite_beholder_parses_bundle_line_with_gzip() {
3909        let mut b = ViteBuildBeholder::default();
3910        let chunk = vite_chunk(
3911            "dist/assets/index-CKBFsjV8.js   141.01 kB \u{2502} gzip:  45.33 kB\n",
3912            1,
3913        );
3914        let events = b.parse_chunk(&chunk);
3915        assert_eq!(events.len(), 1);
3916        let ev = &events[0];
3917        assert_eq!(ev.level, Level::Info);
3918        assert_eq!(ev.target, "vite-build::bundle");
3919        assert_eq!(ev.fields["file"]["path"], "dist/assets/index-CKBFsjV8.js");
3920        assert!((ev.fields["build"]["size_kb"].as_f64().unwrap() - 141.01).abs() < 0.01);
3921        assert!((ev.fields["build"]["gzip_kb"].as_f64().unwrap() - 45.33).abs() < 0.01);
3922    }
3923
3924    #[test]
3925    fn vite_beholder_parses_bundle_line_no_gzip() {
3926        let mut b = ViteBuildBeholder::default();
3927        let chunk = vite_chunk("dist/index.html   0.46 kB\n", 1);
3928        let events = b.parse_chunk(&chunk);
3929        assert_eq!(events.len(), 1);
3930        assert_eq!(events[0].target, "vite-build::bundle");
3931        assert!((events[0].fields["build"]["size_kb"].as_f64().unwrap() - 0.46).abs() < 0.01);
3932        assert!(events[0].fields["build"].get("gzip_kb").is_none()
3933            || events[0].fields["build"]["gzip_kb"].is_null());
3934    }
3935
3936    #[test]
3937    fn vite_beholder_parses_built_timing() {
3938        let mut b = ViteBuildBeholder::default();
3939        let chunk = vite_chunk("\u{2713} built in 2.43s\n", 1);
3940        let events = b.parse_chunk(&chunk);
3941        assert_eq!(events.len(), 1);
3942        let ev = &events[0];
3943        assert_eq!(ev.level, Level::Info);
3944        assert_eq!(ev.target, "vite-build");
3945        assert_eq!(ev.fields["build"]["duration_ms"], 2430u64);
3946        assert_eq!(ev.fields["build"]["success"], true);
3947    }
3948
3949    #[test]
3950    fn vite_beholder_parses_built_timing_no_checkmark() {
3951        let mut b = ViteBuildBeholder::default();
3952        let chunk = vite_chunk("built in 0.99s\n", 1);
3953        let events = b.parse_chunk(&chunk);
3954        assert_eq!(events.len(), 1);
3955        assert_eq!(events[0].fields["build"]["duration_ms"], 990u64);
3956    }
3957
3958    #[test]
3959    fn vite_beholder_ignores_unrelated_lines() {
3960        let mut b = ViteBuildBeholder::default();
3961        let chunk = vite_chunk(
3962            "vite v5.4.0 building for production...\n\
3963             \u{2713} 1234 modules transformed.\n",
3964            1,
3965        );
3966        let events = b.parse_chunk(&chunk);
3967        assert!(events.is_empty(), "got unexpected events: {events:?}");
3968    }
3969
3970    #[test]
3971    fn vite_beholder_multiple_events_one_chunk() {
3972        let mut b = ViteBuildBeholder::default();
3973        let chunk = vite_chunk(
3974            "(!) Use of eval is strongly discouraged.\n\
3975             dist/assets/index.js   50.00 kB \u{2502} gzip: 15.00 kB\n\
3976             \u{2713} built in 1.50s\n",
3977            1,
3978        );
3979        let events = b.parse_chunk(&chunk);
3980        assert_eq!(events.len(), 3);
3981        assert_eq!(events[0].level, Level::Warn);
3982        assert_eq!(events[1].target, "vite-build::bundle");
3983        assert_eq!(events[2].target, "vite-build");
3984    }
3985
3986    #[test]
3987    fn vite_beholder_chunks_split_across_calls() {
3988        let line = "(!) Large chunk warning.\n";
3989        let mid = line.len() / 2;
3990        let mut b = ViteBuildBeholder::default();
3991        let ev1 = b.parse_chunk(&vite_chunk(&line[..mid], 1));
3992        let ev2 = b.parse_chunk(&vite_chunk(&line[mid..], 2));
3993        assert!(ev1.is_empty(), "partial line should not emit");
3994        assert_eq!(ev2.len(), 1);
3995        assert_eq!(ev2[0].level, Level::Warn);
3996    }
3997
3998    // ─── parse_vite_built_ms unit tests ───────────────────────────────────────
3999
4000    #[test]
4001    fn parse_vite_built_ms_with_checkmark() {
4002        assert_eq!(parse_vite_built_ms("\u{2713} built in 2.43s"), Some(2430));
4003    }
4004
4005    #[test]
4006    fn parse_vite_built_ms_bare() {
4007        assert_eq!(parse_vite_built_ms("built in 0.50s"), Some(500));
4008    }
4009
4010    #[test]
4011    fn parse_vite_built_ms_not_a_timing_line() {
4012        assert!(parse_vite_built_ms("vite v5.0.0 building for production...").is_none());
4013        assert!(parse_vite_built_ms("").is_none());
4014    }
4015}