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