Skip to main content

provenant/parsers/
utils.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4/// Shared utility functions for package parsers
5///
6/// This module provides common file I/O and parsing utilities
7/// used across multiple parser implementations.
8use std::collections::HashSet;
9use std::fs::{self, File};
10use std::hash::Hash;
11use std::io::Read;
12use std::path::Path;
13
14use anyhow::Result;
15use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
16use packageurl::PackageUrl;
17
18/// Default maximum file size for non-archive manifest files (100 MB).
19pub const MAX_MANIFEST_SIZE: u64 = 100 * 1024 * 1024;
20
21/// Default maximum length for individual string field values (10 MB).
22pub const MAX_FIELD_LENGTH: usize = 10 * 1024 * 1024;
23
24/// Default maximum iteration count for loops processing items (100,000).
25pub const MAX_ITERATION_COUNT: usize = 100_000;
26
27/// Returns the number of items to iterate for a collection of `total_len`,
28/// capped at [`MAX_ITERATION_COUNT`], and emits a [`crate::parser_warn!`]
29/// diagnostic when the cap actually truncates the input.
30///
31/// Use this in place of a bare `.take(MAX_ITERATION_COUNT)` when iterating a
32/// collection whose length is cheaply known, so silently-dropped entries
33/// become diagnosable in structured scan output. The warning fires only when
34/// `total_len` exceeds the cap, so normal (under-cap) files stay quiet.
35///
36/// `context` should name the file or section being truncated (for example
37/// `"pnpm lockfile packages"`) so the diagnostic identifies what was dropped.
38pub fn capped_iteration_limit(total_len: usize, context: &str) -> usize {
39    if total_len > MAX_ITERATION_COUNT {
40        crate::parser_warn!(
41            "Truncated {} from {} to {} entries (MAX_ITERATION_COUNT); {} entries dropped",
42            context,
43            total_len,
44            MAX_ITERATION_COUNT,
45            total_len - MAX_ITERATION_COUNT
46        );
47    }
48    total_len.min(MAX_ITERATION_COUNT)
49}
50
51/// Iterator adapter that yields at most [`MAX_ITERATION_COUNT`] items and emits
52/// a [`crate::parser_warn!`] diagnostic, naming `context`, if the underlying
53/// iterator actually had more items than the cap.
54///
55/// Created by [`CappedIterExt::capped`]. Use this for the lazy-iterator case
56/// where a collection length is not cheaply known, so truncation stays bounded
57/// and lazy yet becomes diagnosable. The warning fires once, when the cap is
58/// first exceeded; under-cap iterators stay quiet.
59pub struct CappedIter<I: Iterator> {
60    inner: I,
61    context: &'static str,
62    yielded: usize,
63    warned: bool,
64}
65
66impl<I: Iterator> Iterator for CappedIter<I> {
67    type Item = I::Item;
68
69    fn next(&mut self) -> Option<Self::Item> {
70        if self.yielded >= MAX_ITERATION_COUNT {
71            // Probe a single item beyond the cap to detect (and report) real
72            // truncation without consuming the rest of the iterator.
73            if !self.warned && self.inner.next().is_some() {
74                self.warned = true;
75                crate::parser_warn!(
76                    "Truncated {} at {} entries (MAX_ITERATION_COUNT); additional entries dropped",
77                    self.context,
78                    MAX_ITERATION_COUNT
79                );
80            }
81            return None;
82        }
83        let item = self.inner.next();
84        if item.is_some() {
85            self.yielded += 1;
86        }
87        item
88    }
89
90    fn size_hint(&self) -> (usize, Option<usize>) {
91        // We yield at most the cap, minus what we've already yielded; clamp the
92        // inner hint so `collect()` callers don't over-allocate.
93        let remaining_cap = MAX_ITERATION_COUNT.saturating_sub(self.yielded);
94        let (lower, upper) = self.inner.size_hint();
95        let upper = match upper {
96            Some(upper) => upper.min(remaining_cap),
97            None => remaining_cap,
98        };
99        (lower.min(remaining_cap), Some(upper))
100    }
101}
102
103/// Extension trait providing [`capped`](CappedIterExt::capped) on any iterator.
104pub trait CappedIterExt: Iterator + Sized {
105    /// Caps iteration at [`MAX_ITERATION_COUNT`], warning (via
106    /// [`crate::parser_warn!`]) only if the source actually had more items.
107    ///
108    /// Prefer [`capped_iteration_limit`] when the collection length is cheaply
109    /// known; use this for lazy iterators where it is not.
110    fn capped(self, context: &'static str) -> CappedIter<Self> {
111        CappedIter {
112            inner: self,
113            context,
114            yielded: 0,
115            warned: false,
116        }
117    }
118}
119
120impl<I: Iterator> CappedIterExt for I {}
121
122/// Default maximum recursion depth for recursive parsing functions (50 levels).
123pub const MAX_RECURSION_DEPTH: usize = 50;
124
125/// A reusable guard that tracks recursion depth and detects cycles.
126///
127/// Use this in any recursive parser function to enforce the ADR 0004
128/// recursion depth limit (50 levels) and optionally detect circular
129/// references via a visited set keyed by `K`.
130///
131/// For depth-only tracking (no cycle detection), use `RecursionGuard<()>`
132/// — the unit type implements `Hash + Eq` as a singleton, so the visited
133/// set never grows and `enter`/`leave` are cheap no-ops.
134///
135/// # Type Parameters
136///
137/// * `K` — The key type for cycle detection (e.g., `usize` for package
138///   indices, `String` for dependency names, `PathBuf` for file paths,
139///   or `()` for depth-only tracking).
140///
141/// # Example
142///
143/// ```no_run
144/// use provenant::parsers::utils::RecursionGuard;
145///
146/// fn walk_tree(idx: usize, guard: &mut RecursionGuard<usize>) {
147///     if guard.exceeded() {
148///         return;
149///     }
150///     if guard.enter(idx) {
151///         return;
152///     }
153///     walk_tree(idx + 1, guard);
154///     guard.leave(idx);
155/// }
156/// ```
157pub struct RecursionGuard<K: Hash + Eq> {
158    depth: usize,
159    visited: HashSet<K>,
160}
161
162impl<K: Hash + Eq> RecursionGuard<K> {
163    pub fn new() -> Self {
164        Self {
165            depth: 0,
166            visited: HashSet::new(),
167        }
168    }
169
170    pub fn exceeded(&self) -> bool {
171        self.depth > MAX_RECURSION_DEPTH
172    }
173
174    pub fn depth(&self) -> usize {
175        self.depth
176    }
177
178    pub fn enter(&mut self, key: K) -> bool {
179        if self.visited.contains(&key) {
180            return true;
181        }
182        self.visited.insert(key);
183        self.depth += 1;
184        false
185    }
186
187    pub fn leave(&mut self, key: K) {
188        self.visited.remove(&key);
189        self.depth -= 1;
190    }
191}
192
193impl RecursionGuard<()> {
194    pub fn depth_only() -> Self {
195        Self::new()
196    }
197
198    pub fn descend(&mut self) -> bool {
199        self.depth += 1;
200        self.exceeded()
201    }
202
203    pub fn ascend(&mut self) {
204        self.depth -= 1;
205    }
206}
207
208impl<K: Hash + Eq> Default for RecursionGuard<K> {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214/// Truncates a string field value to [`MAX_FIELD_LENGTH`] bytes if it exceeds
215/// the limit, returning the truncated string. Returns the original string if
216/// within limits.
217pub fn truncate_field(value: String) -> String {
218    if value.len() <= MAX_FIELD_LENGTH {
219        return value;
220    }
221    let truncated = &value[..value.floor_char_boundary(MAX_FIELD_LENGTH)];
222    crate::parser_warn!(
223        "Truncated field value from {} bytes to {} bytes (MAX_FIELD_LENGTH)",
224        value.len(),
225        truncated.len()
226    );
227    truncated.to_string()
228}
229
230/// Reads a file's entire contents into a String with ADR 0004 security checks.
231///
232/// Performs the following validations before reading:
233/// 1. **File existence**: checks `fs::metadata()` before opening
234/// 2. **File size**: rejects files exceeding `max_size` (default 100 MB)
235/// 3. **UTF-8 encoding**: on UTF-8 failure, falls back to lossy conversion with a warning
236///
237/// # Arguments
238///
239/// * `path` - Path to the file to read
240/// * `max_size` - Maximum allowed file size in bytes (defaults to [`MAX_MANIFEST_SIZE`])
241///
242/// # Returns
243///
244/// * `Ok(String)` - File contents as UTF-8 string (lossy if non-UTF-8 bytes found)
245/// * `Err` - File doesn't exist, is too large, or cannot be read
246///
247/// Typical usage is `read_file_to_string(path, None)` for the default size
248/// limit, or `read_file_to_string(path, Some(limit))` when a tighter bound is
249/// needed.
250pub fn read_file_to_string(path: &Path, max_size: Option<u64>) -> Result<String> {
251    let limit = max_size.unwrap_or(MAX_MANIFEST_SIZE);
252
253    let metadata =
254        fs::metadata(path).map_err(|e| anyhow::anyhow!("Cannot stat file {:?}: {}", path, e))?;
255
256    if metadata.len() > limit {
257        anyhow::bail!(
258            "File {:?} is {} bytes, exceeding the {} byte limit",
259            path,
260            metadata.len(),
261            limit
262        );
263    }
264
265    let mut bytes = Vec::with_capacity(metadata.len() as usize);
266    let mut file = File::open(path)?;
267    file.read_to_end(&mut bytes)?;
268
269    match String::from_utf8(bytes) {
270        Ok(s) => Ok(s),
271        Err(err) => {
272            let bytes = err.into_bytes();
273            crate::parser_warn!(
274                "File {:?} contains invalid UTF-8; using lossy conversion",
275                path
276            );
277            Ok(String::from_utf8_lossy(&bytes).into_owned())
278        }
279    }
280}
281
282/// Creates a correctly-formatted npm Package URL for scoped or regular packages.
283///
284/// Handles namespace encoding for scoped packages (e.g., `@babel/core`) and ensures
285/// the slash between namespace and package name is NOT encoded as `%2F`.
286pub fn npm_purl(full_name: &str, version: Option<&str>) -> Option<String> {
287    let (namespace, name) = if full_name.starts_with('@') {
288        let parts: Vec<&str> = full_name.splitn(2, '/').collect();
289        if parts.len() == 2 {
290            (Some(parts[0]), parts[1])
291        } else {
292            (None, full_name)
293        }
294    } else {
295        (None, full_name)
296    };
297
298    let mut purl = PackageUrl::new("npm", name).ok()?;
299
300    if let Some(ns) = namespace {
301        purl.with_namespace(ns).ok()?;
302    }
303
304    if let Some(ver) = version {
305        purl.with_version(ver).ok()?;
306    }
307
308    Some(purl.to_string())
309}
310
311/// Parses Subresource Integrity (SRI) format and returns hash as hex string.
312///
313/// SRI format: "algorithm-base64string" (e.g., "sha512-9NET910DNaIPng...")
314///
315/// Returns the algorithm name and hex-encoded hash digest.
316pub fn parse_sri(integrity: &str) -> Option<(String, String)> {
317    let parts: Vec<&str> = integrity.splitn(2, '-').collect();
318    if parts.len() != 2 {
319        return None;
320    }
321
322    let algorithm = parts[0];
323    let base64_str = parts[1];
324
325    let bytes = BASE64_STANDARD.decode(base64_str).ok()?;
326
327    let hex_string = bytes
328        .iter()
329        .map(|b| format!("{:02x}", b))
330        .collect::<String>();
331
332    Some((algorithm.to_string(), hex_string))
333}
334
335/// Parses "Name <email@domain.com>" format into separate components.
336///
337/// This utility handles common author/maintainer strings found in package manifests
338/// where the format combines a human-readable name with an email address in angle brackets.
339///
340/// # Arguments
341///
342/// * `s` - A string potentially containing name and email in "Name \<email\>" format
343///
344/// # Returns
345///
346/// A tuple of `(Option<String>, Option<String>)` representing `(name, email)`:
347/// - If `\<email\>` pattern found: name (trimmed, or None if empty) and email
348/// - If no pattern: trimmed input as name, None for email
349///
350/// Examples: `John Doe <john@example.com>` becomes `(Some("John Doe"),
351/// Some("john@example.com"))`, `<john@example.com>` becomes `(None,
352/// Some("john@example.com"))`, and `John Doe` becomes
353/// `(Some("John Doe"), None)`.
354pub fn split_name_email(s: &str) -> (Option<String>, Option<String>) {
355    if let Some(email_start) = s.find('<')
356        && let Some(email_end) = s.find('>')
357        && email_start < email_end
358    {
359        let name = s[..email_start].trim();
360        let email = &s[email_start + 1..email_end];
361        (
362            if name.is_empty() {
363                None
364            } else {
365                Some(name.to_string())
366            },
367            Some(email.to_string()),
368        )
369    } else {
370        (Some(s.trim().to_string()), None)
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use std::io::Write;
378    use tempfile::tempdir;
379
380    #[test]
381    fn test_recursion_guard_tracks_depth_and_cycles() {
382        let mut guard = RecursionGuard::new();
383
384        assert_eq!(guard.depth(), 0);
385        assert!(!guard.exceeded());
386
387        assert!(!guard.enter("root"));
388        assert_eq!(guard.depth(), 1);
389        assert!(!guard.enter("child"));
390        assert_eq!(guard.depth(), 2);
391
392        assert!(guard.enter("root"));
393        assert_eq!(guard.depth(), 2);
394
395        guard.leave("child");
396        assert_eq!(guard.depth(), 1);
397        guard.leave("root");
398        assert_eq!(guard.depth(), 0);
399        assert!(!guard.exceeded());
400    }
401
402    #[test]
403    fn test_recursion_guard_depth_limit_and_depth_only_mode() {
404        let mut guard = RecursionGuard::<()>::depth_only();
405
406        for _ in 0..MAX_RECURSION_DEPTH {
407            assert!(!guard.descend());
408        }
409
410        assert_eq!(guard.depth(), MAX_RECURSION_DEPTH);
411        assert!(!guard.exceeded());
412
413        assert!(guard.descend());
414        assert_eq!(guard.depth(), MAX_RECURSION_DEPTH + 1);
415        assert!(guard.exceeded());
416
417        guard.ascend();
418        assert_eq!(guard.depth(), MAX_RECURSION_DEPTH);
419        assert!(!guard.exceeded());
420    }
421
422    #[test]
423    fn test_read_file_to_string_success() {
424        let dir = tempdir().unwrap();
425        let file_path = dir.path().join("test.txt");
426        let mut file = File::create(&file_path).unwrap();
427        file.write_all(b"test content").unwrap();
428
429        let content = read_file_to_string(&file_path, None).unwrap();
430        assert_eq!(content, "test content");
431    }
432
433    #[test]
434    fn test_read_file_to_string_nonexistent() {
435        let path = Path::new("/nonexistent/file.txt");
436        let result = read_file_to_string(path, None);
437        assert!(result.is_err());
438    }
439
440    #[test]
441    fn test_read_file_to_string_empty() {
442        let dir = tempdir().unwrap();
443        let file_path = dir.path().join("empty.txt");
444        File::create(&file_path).unwrap();
445
446        let content = read_file_to_string(&file_path, None).unwrap();
447        assert_eq!(content, "");
448    }
449
450    #[test]
451    fn test_npm_purl_scoped_with_version() {
452        let purl = npm_purl("@babel/core", Some("7.0.0")).unwrap();
453        assert_eq!(purl, "pkg:npm/%40babel/core@7.0.0");
454    }
455
456    #[test]
457    fn test_npm_purl_scoped_without_version() {
458        let purl = npm_purl("@babel/core", None).unwrap();
459        assert_eq!(purl, "pkg:npm/%40babel/core");
460    }
461
462    #[test]
463    fn test_npm_purl_unscoped_with_version() {
464        let purl = npm_purl("lodash", Some("4.17.21")).unwrap();
465        assert_eq!(purl, "pkg:npm/lodash@4.17.21");
466    }
467
468    #[test]
469    fn test_npm_purl_unscoped_without_version() {
470        let purl = npm_purl("lodash", None).unwrap();
471        assert_eq!(purl, "pkg:npm/lodash");
472    }
473
474    #[test]
475    fn test_npm_purl_scoped_slash_not_encoded() {
476        let purl = npm_purl("@types/node", Some("18.0.0")).unwrap();
477        assert!(purl.contains("/%40types/node"));
478        assert!(!purl.contains("%2F"));
479    }
480
481    #[test]
482    fn test_parse_sri_sha512() {
483        let (algo, hash) = parse_sri("sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==").unwrap();
484        assert_eq!(algo, "sha512");
485        assert_eq!(hash.len(), 128);
486    }
487
488    #[test]
489    fn test_parse_sri_sha1() {
490        let (algo, hash) = parse_sri("sha1-w7M6te42DYbg5ijwRorn7yfWVN8=").unwrap();
491        assert_eq!(algo, "sha1");
492        assert_eq!(hash.len(), 40);
493    }
494
495    #[test]
496    fn test_parse_sri_sha256() {
497        let (algo, hash) =
498            parse_sri("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=").unwrap();
499        assert_eq!(algo, "sha256");
500        assert_eq!(hash.len(), 64);
501    }
502
503    #[test]
504    fn test_parse_sri_invalid_format() {
505        assert!(parse_sri("invalid").is_none());
506        assert!(parse_sri("sha512").is_none());
507        assert!(parse_sri("").is_none());
508    }
509
510    #[test]
511    fn test_parse_sri_invalid_base64() {
512        assert!(parse_sri("sha512-!!!invalid!!!").is_none());
513    }
514
515    #[test]
516    fn test_split_name_email_full_format() {
517        let (name, email) = split_name_email("John Doe <john@example.com>");
518        assert_eq!(name, Some("John Doe".to_string()));
519        assert_eq!(email, Some("john@example.com".to_string()));
520    }
521
522    #[test]
523    fn test_split_name_email_name_only() {
524        let (name, email) = split_name_email("John Doe");
525        assert_eq!(name, Some("John Doe".to_string()));
526        assert_eq!(email, None);
527    }
528
529    #[test]
530    fn test_split_name_email_email_only_plain() {
531        let (name, email) = split_name_email("john@example.com");
532        assert_eq!(name, Some("john@example.com".to_string()));
533        assert_eq!(email, None);
534    }
535
536    #[test]
537    fn test_split_name_email_email_only_brackets() {
538        let (name, email) = split_name_email("<john@example.com>");
539        assert_eq!(name, None);
540        assert_eq!(email, Some("john@example.com".to_string()));
541    }
542
543    #[test]
544    fn test_split_name_email_whitespace_trimming() {
545        let (name, email) = split_name_email("  John Doe  <  john@example.com  >  ");
546        assert_eq!(name, Some("John Doe".to_string()));
547        assert_eq!(email, Some("  john@example.com  ".to_string()));
548    }
549
550    #[test]
551    fn test_split_name_email_empty_string() {
552        let (name, email) = split_name_email("");
553        assert_eq!(name, Some("".to_string()));
554        assert_eq!(email, None);
555    }
556
557    #[test]
558    fn test_split_name_email_whitespace_only() {
559        let (name, email) = split_name_email("   ");
560        assert_eq!(name, Some("".to_string()));
561        assert_eq!(email, None);
562    }
563
564    #[test]
565    fn test_split_name_email_invalid_bracket_order() {
566        let (name, email) = split_name_email("John >email< Doe");
567        assert_eq!(name, Some("John >email< Doe".to_string()));
568        assert_eq!(email, None);
569    }
570
571    #[test]
572    fn test_split_name_email_missing_close_bracket() {
573        let (name, email) = split_name_email("John Doe <email@example.com");
574        assert_eq!(name, Some("John Doe <email@example.com".to_string()));
575        assert_eq!(email, None);
576    }
577
578    #[test]
579    fn test_split_name_email_missing_open_bracket() {
580        let (name, email) = split_name_email("John Doe email@example.com>");
581        assert_eq!(name, Some("John Doe email@example.com>".to_string()));
582        assert_eq!(email, None);
583    }
584
585    #[test]
586    fn test_read_file_to_string_oversized() {
587        let dir = tempdir().unwrap();
588        let file_path = dir.path().join("big.txt");
589        fs::write(&file_path, "x").unwrap();
590
591        let result = read_file_to_string(&file_path, Some(0));
592        assert!(result.is_err());
593    }
594
595    #[test]
596    fn test_read_file_to_string_lossy_utf8() {
597        let dir = tempdir().unwrap();
598        let file_path = dir.path().join("bad_utf8.txt");
599        let mut file = File::create(&file_path).unwrap();
600        file.write_all(b"hello\xffworld").unwrap();
601
602        let content = read_file_to_string(&file_path, None).unwrap();
603        assert!(content.contains("hello"));
604        assert!(content.contains("world"));
605    }
606
607    #[test]
608    fn test_truncate_field_within_limit() {
609        let s = "short value".to_string();
610        assert_eq!(truncate_field(s.clone()), s);
611    }
612
613    #[test]
614    fn test_truncate_field_exceeds_limit() {
615        let long = "x".repeat(MAX_FIELD_LENGTH + 100);
616        let truncated = truncate_field(long);
617        assert!(truncated.len() <= MAX_FIELD_LENGTH);
618    }
619
620    #[test]
621    fn test_capped_iteration_limit_under_cap() {
622        assert_eq!(capped_iteration_limit(0, "test"), 0);
623        assert_eq!(capped_iteration_limit(10, "test"), 10);
624        assert_eq!(
625            capped_iteration_limit(MAX_ITERATION_COUNT, "test"),
626            MAX_ITERATION_COUNT
627        );
628    }
629
630    #[test]
631    fn test_capped_iteration_limit_truncates() {
632        assert_eq!(
633            capped_iteration_limit(MAX_ITERATION_COUNT + 1, "test"),
634            MAX_ITERATION_COUNT
635        );
636        assert_eq!(
637            capped_iteration_limit(MAX_ITERATION_COUNT * 2, "test"),
638            MAX_ITERATION_COUNT
639        );
640    }
641
642    #[test]
643    fn test_capped_iteration_limit_warns_only_when_truncating() {
644        use crate::models::DiagnosticSeverity;
645        use crate::parsers::capture_parser_diagnostics;
646        use std::path::Path;
647
648        // Under the cap: no diagnostic.
649        let quiet = capture_parser_diagnostics(
650            || {
651                let _ = capped_iteration_limit(10, "under-cap context");
652                Vec::new()
653            },
654            "test",
655            Path::new("test"),
656            None,
657        );
658        assert!(
659            quiet.scan_diagnostics.is_empty(),
660            "expected no diagnostic under the cap, got: {:?}",
661            quiet.scan_diagnostics
662        );
663
664        // Over the cap: a warning naming the context and the cap.
665        let noisy = capture_parser_diagnostics(
666            || {
667                let _ = capped_iteration_limit(MAX_ITERATION_COUNT + 7, "over-cap context");
668                Vec::new()
669            },
670            "test",
671            Path::new("test"),
672            None,
673        );
674        assert!(
675            noisy.scan_diagnostics.iter().any(|diagnostic| {
676                diagnostic.severity == DiagnosticSeverity::Warning
677                    && diagnostic.message.contains("over-cap context")
678                    && diagnostic.message.contains("MAX_ITERATION_COUNT")
679            }),
680            "expected a truncation warning naming the context, got: {:?}",
681            noisy.scan_diagnostics
682        );
683    }
684
685    #[test]
686    fn test_capped_iter_under_cap_is_quiet_and_complete() {
687        use crate::parsers::capture_parser_diagnostics;
688        use std::path::Path;
689
690        let result = capture_parser_diagnostics(
691            || {
692                let collected: Vec<usize> = (0..10).capped("under-cap iter").collect();
693                assert_eq!(collected, (0..10).collect::<Vec<_>>());
694                Vec::new()
695            },
696            "test",
697            Path::new("test"),
698            None,
699        );
700        assert!(
701            result.scan_diagnostics.is_empty(),
702            "expected no diagnostic under the cap, got: {:?}",
703            result.scan_diagnostics
704        );
705    }
706
707    #[test]
708    fn test_capped_iter_truncates_and_warns() {
709        use crate::models::DiagnosticSeverity;
710        use crate::parsers::capture_parser_diagnostics;
711        use std::path::Path;
712
713        let result = capture_parser_diagnostics(
714            || {
715                let count = (0..MAX_ITERATION_COUNT + 5).capped("over-cap iter").count();
716                assert_eq!(count, MAX_ITERATION_COUNT);
717                Vec::new()
718            },
719            "test",
720            Path::new("test"),
721            None,
722        );
723        assert!(
724            result.scan_diagnostics.iter().any(|diagnostic| {
725                diagnostic.severity == DiagnosticSeverity::Warning
726                    && diagnostic.message.contains("over-cap iter")
727                    && diagnostic.message.contains("MAX_ITERATION_COUNT")
728            }),
729            "expected a truncation warning naming the context, got: {:?}",
730            result.scan_diagnostics
731        );
732    }
733}