Skip to main content

sphinx_ultra/
inventory.rs

1//! `objects.inv` reader/writer — mirrors `sphinx.util.inventory` (Sphinx 9.1.0).
2//!
3//! The file is framed as: one `\n`-terminated ASCII header line naming the
4//! format version, followed by version-specific content. Version 2 (the only
5//! format any Sphinx release still *writes*, though 1 is still read) packs
6//! three more header lines and then a raw zlib-compressed byte tail — never
7//! text, never line-split, never re-encoded — that decompresses to one text
8//! record per object. Every byte-framing decision below (`partition`/`split`
9//! at `\n`, blind column-11 header slices, the exact regex, `$`-suffix
10//! expansion happening *before* the posixpath join, dispname `-` stored
11//! verbatim) mirrors `sphinx/util/inventory.py` `InventoryFile.loads` /
12//! `_loads_v1` / `_loads_v2` / `dump` byte-for-byte; see
13//! docs/superpowers/plans/2026-08-31-m2-wave4-research-spec-inventory-intersphinx.md
14//! §1 for the file:line citations this was built against.
15//!
16//! The previous reader here decoded the whole file with
17//! `String::from_utf8_lossy` and iterated `.lines()` over it — which mangles
18//! the raw zlib payload (lossy UTF-8 replacement + line-splitting on bytes
19//! that were never text) on every real-world inventory. This rewrite never
20//! treats the compressed tail as anything but a byte slice until *after*
21//! `zlib::decompress` has run.
22
23use anyhow::{Context, Result};
24use flate2::read::ZlibDecoder;
25use flate2::write::ZlibEncoder;
26use flate2::Compression;
27use log::{debug, info};
28use serde::{Deserialize, Serialize};
29use std::collections::{BTreeMap, HashMap, HashSet};
30use std::io::{Read, Write};
31use std::path::Path;
32use tokio::fs;
33
34/// Inventory item representing a single object in the documentation
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
36pub struct InventoryItem {
37    pub project_name: String,
38    pub project_version: String,
39    pub uri: String,
40    pub display_name: String,
41}
42
43impl InventoryItem {
44    pub fn new(
45        project_name: String,
46        project_version: String,
47        uri: String,
48        display_name: String,
49    ) -> Self {
50        Self {
51            project_name,
52            project_version,
53            uri,
54            display_name,
55        }
56    }
57}
58
59/// In-memory inventory data structure.
60///
61/// `BTreeMap`, not `HashMap`, because intersphinx's case-insensitive
62/// `std:label`/`std:term` fallback picks the *first* key that matches
63/// case-folded (`ext/intersphinx/_resolve.py:104-127`) — in Python, dict
64/// insertion order, i.e. the order the entries appear in the file. Every
65/// inventory Sphinx writes is sorted per objtype (`inventory.py:194-196`
66/// sorts each domain's objects), so key order *is* sorted order there, and a
67/// `BTreeMap` reproduces that choice deterministically instead of leaving it
68/// to a hash seed.
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub struct Inventory {
71    pub data: BTreeMap<String, BTreeMap<String, InventoryItem>>,
72}
73
74impl Inventory {
75    pub fn new() -> Self {
76        Self {
77            data: BTreeMap::new(),
78        }
79    }
80
81    /// Insert an item into the inventory
82    pub fn insert(&mut self, obj_type: String, name: String, item: InventoryItem) {
83        self.data.entry(obj_type).or_default().insert(name, item);
84    }
85
86    /// Get an item from the inventory
87    pub fn get(&self, obj_type: &str, name: &str) -> Option<&InventoryItem> {
88        self.data.get(obj_type)?.get(name)
89    }
90
91    /// Check if an item exists in the inventory
92    pub fn contains(&self, obj_type: &str, name: &str) -> bool {
93        self.data
94            .get(obj_type)
95            .is_some_and(|objects| objects.contains_key(name))
96    }
97}
98
99/// One object record to write, in the shape `domain.get_objects()` yields:
100/// `name` is the fully-qualified object name (`fullname` in Sphinx), `objtype`
101/// is the bare type within its domain (no `domain:` prefix — the domain name
102/// is supplied separately by [`InventoryFile::dump`]'s `domains` argument).
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct InvObject {
105    pub name: String,
106    pub objtype: String,
107    pub priority: i32,
108    pub docname: String,
109    pub anchor: String,
110    pub dispname: String,
111}
112
113/// posixpath.join(uri, location) semantics: an absolute `location` (leading
114/// `/`) replaces `uri` outright; otherwise `location` is appended, with a
115/// `/` inserted only if `uri` is non-empty and doesn't already end in one.
116/// (`sphinx/util/inventory.py:79,157` — `location = posixpath.join(uri, location)`.)
117pub fn posix_join(uri: &str, location: &str) -> String {
118    if location.starts_with('/') {
119        location.to_string()
120    } else if uri.is_empty() || uri.ends_with('/') {
121        format!("{uri}{location}")
122    } else {
123        format!("{uri}/{location}")
124    }
125}
126
127lazy_static::lazy_static! {
128    /// `r'(.+?)\s+(\S+)\s+(-?\d+)\s+?(\S*)\s+(.*)'`, anchored at the start to
129    /// match Python's `re.match` (which only requires the match to *start*
130    /// at position 0, not consume the whole line) — `inventory.py:115-119`.
131    static ref V2_LINE_RE: regex::Regex =
132        regex::Regex::new(r"^(.+?)\s+(\S+)\s+(-?\d+)\s+?(\S*)\s+(.*)").unwrap();
133    /// Header-field whitespace-run collapse: `re.sub('\s+', ' ', s)` — applied
134    /// only to the Project/Version header values, never to entry lines
135    /// (`inventory.py:178-179`).
136    static ref WHITESPACE_RUN_RE: regex::Regex = regex::Regex::new(r"\s+").unwrap();
137}
138
139/// Inventory file handler - mirrors Sphinx's InventoryFile class
140pub struct InventoryFile;
141
142impl InventoryFile {
143    /// Load inventory from bytes (mirrors Sphinx's `InventoryFile.loads`).
144    ///
145    /// Operates on raw bytes throughout the header framing and the
146    /// version-2 zlib tail — the compressed payload is never decoded as
147    /// text, never line-split, until *after* `zlib::decompress` succeeds.
148    pub fn loads(content: &[u8], uri: &str) -> Result<Inventory> {
149        let (format_line, rest) = partition_bytes(content, b'\n');
150        let format_line = rstrip_bytes(format_line);
151
152        if format_line == b"# Sphinx inventory version 2" {
153            Self::loads_v2(rest, uri)
154        } else if format_line == b"# Sphinx inventory version 1" {
155            Self::loads_v1(rest, uri)
156        } else if let Some(unknown_version_bytes) =
157            format_line.strip_prefix(b"# Sphinx inventory version ")
158        {
159            let unknown_version = String::from_utf8(unknown_version_bytes.to_vec())
160                .context("inventory header version suffix is not valid UTF-8")?;
161            anyhow::bail!(
162                "unknown or unsupported inventory version: {}",
163                python_repr_str(&unknown_version)
164            );
165        } else {
166            let line = String::from_utf8(format_line.to_vec())
167                .context("inventory header line is not valid UTF-8")?;
168            anyhow::bail!("invalid inventory header: {}", line);
169        }
170    }
171
172    /// Load inventory from file
173    pub async fn load<P: AsRef<Path>>(filename: P, uri: &str) -> Result<Inventory> {
174        let content = fs::read(filename.as_ref()).await.with_context(|| {
175            format!(
176                "Failed to read inventory file: {}",
177                filename.as_ref().display()
178            )
179        })?;
180
181        Self::loads(&content, uri)
182    }
183
184    /// Load inventory from version 1 format (`inventory.py:70-93`).
185    ///
186    /// `content` is everything after the format line's `\n` (still raw
187    /// bytes); v1 is plain text, so the *whole* remainder is UTF-8-decoded
188    /// up front, then split with Python `str.splitlines()` semantics.
189    fn loads_v1(content: &[u8], uri: &str) -> Result<Inventory> {
190        let text =
191            String::from_utf8(content.to_vec()).context("v1 inventory body is not valid UTF-8")?;
192        let lines = python_str_splitlines(&text);
193
194        if lines.len() < 2 {
195            anyhow::bail!("invalid inventory header: missing project name or version");
196        }
197
198        let mut inv = Inventory::new();
199        let projname = str_slice_from_char(lines[0].trim_end(), 11).to_string();
200        let version = str_slice_from_char(lines[1].trim_end(), 11).to_string();
201
202        for line in &lines[2..] {
203            let fields = python_split_none_maxsplit(line.trim_end(), 2);
204            if fields.len() != 3 {
205                anyhow::bail!(
206                    "invalid inventory v1 entry (expected `name type location`): {}",
207                    line
208                );
209            }
210            let (name, item_type, location) = (fields[0], fields[1], fields[2]);
211            let mut location = posix_join(uri, location);
212
213            // v1 did not add anchors to the location; do it here as plain
214            // string concatenation, same as Sphinx (inventory.py:80-86) —
215            // note this happens *after* the join, unlike v2's $-expansion.
216            let domain_type = if item_type == "mod" {
217                location.push_str("#module-");
218                location.push_str(name);
219                "py:module".to_string()
220            } else {
221                location.push('#');
222                location.push_str(name);
223                format!("py:{item_type}")
224            };
225
226            let item =
227                InventoryItem::new(projname.clone(), version.clone(), location, "-".to_string());
228            inv.insert(domain_type, name.to_string(), item);
229        }
230
231        Ok(inv)
232    }
233
234    /// Load inventory from version 2 format (`inventory.py:96-172`).
235    ///
236    /// `content` is everything after the format line's `\n`, still raw
237    /// bytes. Framing (`split(b'\n', maxsplit=3)`), the column-11 header
238    /// slices, and the `zlib` substring check all operate on bytes; only
239    /// the decompressed entry payload is ever treated as text.
240    fn loads_v2(content: &[u8], uri: &str) -> Result<Inventory> {
241        let parts = splitn_bytes(content, b'\n', 4);
242        if parts.len() != 4 {
243            anyhow::bail!("invalid inventory header: missing project name or version");
244        }
245        let (line_1, line_2, check_line, compressed) = (parts[0], parts[1], parts[2], parts[3]);
246
247        // Blind slice at byte column 11 (`len("# Project: ")`), no prefix
248        // validation — inventory.py:103-104.
249        let projname = String::from_utf8(bytes_slice_from(rstrip_bytes(line_1), 11).to_vec())
250            .context("inventory Project header is not valid UTF-8")?;
251        let version = String::from_utf8(bytes_slice_from(rstrip_bytes(line_2), 11).to_vec())
252            .context("inventory Version header is not valid UTF-8")?;
253
254        // check_line is used as-is: NOT rstripped (inventory.py:108-110).
255        if !contains_bytes(check_line, b"zlib") {
256            let check_line_text = String::from_utf8(check_line.to_vec())
257                .context("inventory compression-check line is not valid UTF-8")?;
258            anyhow::bail!(
259                "invalid inventory header (not compressed): {}",
260                check_line_text
261            );
262        }
263
264        let decompressed = decompress_zlib(compressed)?;
265        let decompressed_text = String::from_utf8(decompressed)
266            .context("decompressed inventory payload is not valid UTF-8")?;
267
268        let mut inv = Inventory::new();
269        // definition (lowercased) -> (prio, location, dispname) as parsed,
270        // BEFORE $-expansion/posix_join — inventory.py:106,140.
271        let mut potential_ambiguities: HashMap<String, (String, String, String)> = HashMap::new();
272        let mut actual_ambiguities: HashSet<String> = HashSet::new();
273
274        for line in python_str_splitlines(&decompressed_text) {
275            let trimmed = line.trim_end();
276            let Some(caps) = V2_LINE_RE.captures(trimmed) else {
277                continue;
278            };
279            let name = caps.get(1).unwrap().as_str();
280            let type_ = caps.get(2).unwrap().as_str();
281            let prio = caps.get(3).unwrap().as_str();
282            let mut location = caps.get(4).unwrap().as_str().to_string();
283            let dispname = caps.get(5).unwrap().as_str().to_string();
284
285            if !type_.contains(':') {
286                // Deliberately a plain string check, not part of the regex,
287                // to avoid ReDoS (GH sphinx-doc/sphinx#8175).
288                continue;
289            }
290            if type_ == "py:module" && inv.contains(type_, name) {
291                // Sphinx <=1.1 double-emitted py:module entries; first wins.
292                continue;
293            }
294
295            if type_ == "std:label" || type_ == "std:term" {
296                let definition = format!("{type_}:{name}");
297                let content_key = (prio.to_string(), location.clone(), dispname.clone());
298                let lowercase_definition = definition.to_lowercase();
299                match potential_ambiguities.get(&lowercase_definition) {
300                    Some(existing) if existing == &content_key => {
301                        debug!(
302                            "inventory <{}> contains duplicate definitions of {}",
303                            uri, definition
304                        );
305                    }
306                    Some(_) => {
307                        actual_ambiguities.insert(definition);
308                    }
309                    None => {
310                        potential_ambiguities.insert(lowercase_definition, content_key);
311                    }
312                }
313            }
314
315            if let Some(prefix) = location.strip_suffix('$') {
316                location = format!("{prefix}{name}");
317            }
318            let joined = posix_join(uri, &location);
319
320            let item = InventoryItem::new(projname.clone(), version.clone(), joined, dispname);
321            inv.insert(type_.to_string(), name.to_string(), item);
322        }
323
324        for ambiguity in &actual_ambiguities {
325            info!(
326                "inventory <{}> contains multiple definitions for {}",
327                uri, ambiguity
328            );
329        }
330
331        Ok(inv)
332    }
333
334    /// Write inventory to `path` in Sphinx's version-2 format
335    /// (mirrors `InventoryFile.dump`, `inventory.py:174-207`).
336    ///
337    /// Decoupled from `BuildEnvironment`/`Builder`: callers supply the
338    /// already-collected per-domain object lists and a `get_target_uri`
339    /// closure (`Builder.get_target_uri(docname)` in Sphinx) instead of
340    /// live env/builder references.
341    ///
342    /// `domains` need not be pre-sorted — this sorts domains alphabetically
343    /// by name and, within each domain, sorts its objects by
344    /// `(name, dispname, objtype, docname, anchor, priority)`, mirroring
345    /// `env.domains.sorted()` + `sorted(domain.get_objects())`
346    /// (`inventory.py:194-196`).
347    pub async fn dump<P: AsRef<Path>>(
348        path: P,
349        project: &str,
350        version: &str,
351        domains: &[(&str, Vec<InvObject>)],
352        get_target_uri: impl Fn(&str) -> String,
353    ) -> Result<()> {
354        let header = format!(
355            "# Sphinx inventory version 2\n\
356             # Project: {}\n\
357             # Version: {}\n\
358             # The remainder of this file is compressed using zlib.\n",
359            Self::escape_string(project),
360            Self::escape_string(version),
361        );
362
363        let mut sorted_domains: Vec<&(&str, Vec<InvObject>)> = domains.iter().collect();
364        sorted_domains.sort_by_key(|(name, _)| *name);
365
366        let mut body = Vec::new();
367        for (domain_name, objects) in sorted_domains {
368            let mut objects: Vec<&InvObject> = objects.iter().collect();
369            objects.sort_by(|a, b| {
370                a.name
371                    .cmp(&b.name)
372                    .then_with(|| a.dispname.cmp(&b.dispname))
373                    .then_with(|| a.objtype.cmp(&b.objtype))
374                    .then_with(|| a.docname.cmp(&b.docname))
375                    .then_with(|| a.anchor.cmp(&b.anchor))
376                    .then_with(|| a.priority.cmp(&b.priority))
377            });
378
379            for obj in objects {
380                // `if anchor.endswith(fullname): anchor = anchor.removesuffix(fullname) + '$'`
381                // (inventory.py:197-199) — up to ~25% size saving.
382                let anchor = match obj.anchor.strip_suffix(obj.name.as_str()) {
383                    Some(prefix) => format!("{prefix}$"),
384                    None => obj.anchor.clone(),
385                };
386
387                // `#` is part of the URI, added before the (possibly
388                // $-abbreviated) anchor, and only when anchor is non-empty
389                // (inventory.py:200-202) — the old writer's missing-`#` bug.
390                let mut uri = get_target_uri(&obj.docname);
391                if !anchor.is_empty() {
392                    uri.push('#');
393                    uri.push_str(&anchor);
394                }
395
396                let dispname: &str = if obj.dispname == obj.name {
397                    "-"
398                } else {
399                    obj.dispname.as_str()
400                };
401
402                let line = format!(
403                    "{} {}:{} {} {} {}\n",
404                    obj.name, domain_name, obj.objtype, obj.priority, uri, dispname
405                );
406                body.extend_from_slice(line.as_bytes());
407            }
408        }
409
410        // One-shot compression of the whole body is byte-equivalent to
411        // Sphinx's per-entry `compressor.compress()` calls with a single
412        // final `flush()` and no intermediate flushes (inventory.py:206-207)
413        // — but see the module doc: flate2's backend never produces
414        // CPython-zlib-identical *compressed* bytes regardless, so
415        // byte-correctness is defined on the decompressed payload only.
416        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::new(9));
417        encoder
418            .write_all(&body)
419            .context("failed to compress inventory body")?;
420        let compressed = encoder
421            .finish()
422            .context("failed to finalize inventory zlib stream")?;
423
424        let mut content = header.into_bytes();
425        content.extend_from_slice(&compressed);
426
427        fs::write(path, content)
428            .await
429            .context("Failed to write inventory file")?;
430
431        Ok(())
432    }
433
434    /// Escape a header field value: whitespace runs collapsed to a single
435    /// space (`re.sub('\s+', ' ', s)`, `inventory.py:178-179`). Applies only
436    /// to the Project/Version header fields — entry lines are not escaped.
437    fn escape_string(s: &str) -> String {
438        WHITESPACE_RUN_RE.replace_all(s, " ").to_string()
439    }
440}
441
442/// `bytes.partition(sep)` — always returns `(head, tail)`; if `sep` isn't
443/// found, `head` is the whole input and `tail` is empty (Python's `partition`
444/// also returns an empty *separator* in that case, which callers here never
445/// need to distinguish from "found at the very end").
446fn partition_bytes(data: &[u8], sep: u8) -> (&[u8], &[u8]) {
447    match data.iter().position(|&b| b == sep) {
448        Some(pos) => (&data[..pos], &data[pos + 1..]),
449        None => (data, &[]),
450    }
451}
452
453/// `bytes.split(sep, maxsplit=n-1)` — at most `n` parts; the last part keeps
454/// any further `sep` bytes un-split. Returns fewer than `n` parts if there
455/// aren't enough separators, exactly like Python.
456fn splitn_bytes(data: &[u8], sep: u8, n: usize) -> Vec<&[u8]> {
457    let mut parts = Vec::with_capacity(n);
458    let mut rest = data;
459    while parts.len() + 1 < n {
460        match rest.iter().position(|&b| b == sep) {
461            Some(pos) => {
462                parts.push(&rest[..pos]);
463                rest = &rest[pos + 1..];
464            }
465            None => break,
466        }
467    }
468    parts.push(rest);
469    parts
470}
471
472/// `bytes.rstrip()` (no argument): strips trailing ASCII whitespace
473/// (space, \t, \n, \r, \x0b, \x0c).
474fn rstrip_bytes(data: &[u8]) -> &[u8] {
475    let mut end = data.len();
476    while end > 0 && matches!(data[end - 1], b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c) {
477        end -= 1;
478    }
479    &data[..end]
480}
481
482/// `data[start:]` on a `bytes` object: never panics/errors even if `start`
483/// is past the end (returns empty), matching Python's forgiving slicing.
484fn bytes_slice_from(data: &[u8], start: usize) -> &[u8] {
485    if start >= data.len() {
486        &[]
487    } else {
488        &data[start..]
489    }
490}
491
492/// `s[start:]` on a `str`, where `start` counts Unicode *characters* (not
493/// bytes) — matching Python's `str` slicing, which is always
494/// character-indexed and never panics on a short string.
495fn str_slice_from_char(s: &str, start: usize) -> &str {
496    match s.char_indices().nth(start) {
497        Some((byte_idx, _)) => &s[byte_idx..],
498        None => "",
499    }
500}
501
502/// `sep in data` for byte slices (Python's `in` on `bytes`).
503fn contains_bytes(data: &[u8], needle: &[u8]) -> bool {
504    if needle.is_empty() {
505        return true;
506    }
507    data.windows(needle.len()).any(|w| w == needle)
508}
509
510/// `str.splitlines()`: splits on `\n`, `\r`, `\r\n` (as one boundary), and
511/// the other line-boundary characters Python recognizes (`\v`, `\f`,
512/// `\x1c`-`\x1e`, `\x85`, U+2028, U+2029). Unlike `str::split`, a trailing
513/// boundary produces no trailing empty element.
514fn python_str_splitlines(s: &str) -> Vec<&str> {
515    let mut lines = Vec::new();
516    let mut start = 0usize;
517    let mut chars = s.char_indices().peekable();
518    while let Some((idx, ch)) = chars.next() {
519        let is_boundary = matches!(
520            ch,
521            '\n' | '\r'
522                | '\u{0b}'
523                | '\u{0c}'
524                | '\u{1c}'
525                | '\u{1d}'
526                | '\u{1e}'
527                | '\u{85}'
528                | '\u{2028}'
529                | '\u{2029}'
530        );
531        if is_boundary {
532            lines.push(&s[start..idx]);
533            let mut end = idx + ch.len_utf8();
534            if ch == '\r' {
535                if let Some(&(_, '\n')) = chars.peek() {
536                    let (nidx, nch) = chars.next().unwrap();
537                    end = nidx + nch.len_utf8();
538                }
539            }
540            start = end;
541        }
542    }
543    if start < s.len() {
544        lines.push(&s[start..]);
545    }
546    lines
547}
548
549/// `s.split(None, maxsplit=n)`: skips leading whitespace, collects up to `n`
550/// whitespace-delimited tokens, then the final element is whatever remains
551/// (its own leading whitespace consumed by the split, but not further
552/// trimmed). An all-whitespace or empty `s` yields an empty `Vec`.
553fn python_split_none_maxsplit(s: &str, maxsplit: usize) -> Vec<&str> {
554    let mut result = Vec::new();
555    let mut rest = s;
556    loop {
557        let trimmed = rest.trim_start();
558        if trimmed.is_empty() {
559            break;
560        }
561        if result.len() == maxsplit {
562            result.push(trimmed);
563            break;
564        }
565        match trimmed.find(char::is_whitespace) {
566            Some(idx) => {
567                result.push(&trimmed[..idx]);
568                rest = &trimmed[idx..];
569            }
570            None => {
571                result.push(trimmed);
572                rest = "";
573            }
574        }
575    }
576    result
577}
578
579/// A reasonable approximation of Python's `repr()` for `str`, sufficient for
580/// the one place it's needed (`{unknown_version!r}` in the unsupported-
581/// inventory-version error, `inventory.py:59-61`): a realistic version
582/// suffix is plain ASCII. Quotes with `'` unless the string contains a `'`
583/// and no `"`, in which case it quotes with `"`; escapes backslashes, the
584/// chosen quote character, and ASCII control characters as `\xNN`. Does NOT
585/// replicate Python's full non-ASCII-category escaping (`\uXXXX` for exotic
586/// Unicode control/separator characters) — out of scope for this field.
587fn python_repr_str(s: &str) -> String {
588    let has_single = s.contains('\'');
589    let has_double = s.contains('"');
590    let quote = if has_single && !has_double { '"' } else { '\'' };
591
592    let mut out = String::with_capacity(s.len() + 2);
593    out.push(quote);
594    for c in s.chars() {
595        match c {
596            '\\' => out.push_str("\\\\"),
597            c if c == quote => {
598                out.push('\\');
599                out.push(c);
600            }
601            '\n' => out.push_str("\\n"),
602            '\r' => out.push_str("\\r"),
603            '\t' => out.push_str("\\t"),
604            c if (c as u32) < 0x20 || (c as u32) == 0x7f => {
605                out.push_str(&format!("\\x{:02x}", c as u32));
606            }
607            c => out.push(c),
608        }
609    }
610    out.push(quote);
611    out
612}
613
614fn decompress_zlib(data: &[u8]) -> Result<Vec<u8>> {
615    let mut decoder = ZlibDecoder::new(data);
616    let mut decompressed = Vec::new();
617    decoder
618        .read_to_end(&mut decompressed)
619        .context("failed to decompress inventory zlib payload")?;
620    Ok(decompressed)
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    #[test]
628    fn test_inventory_item_creation() {
629        let item = InventoryItem::new(
630            "test_project".to_string(),
631            "1.0".to_string(),
632            "http://example.com/test.html".to_string(),
633            "Test Item".to_string(),
634        );
635
636        assert_eq!(item.project_name, "test_project");
637        assert_eq!(item.project_version, "1.0");
638        assert_eq!(item.uri, "http://example.com/test.html");
639        assert_eq!(item.display_name, "Test Item");
640    }
641
642    #[test]
643    fn test_inventory_operations() {
644        let mut inv = Inventory::new();
645
646        let item = InventoryItem::new(
647            "test".to_string(),
648            "1.0".to_string(),
649            "test.html".to_string(),
650            "Test".to_string(),
651        );
652
653        inv.insert(
654            "py:function".to_string(),
655            "test_func".to_string(),
656            item.clone(),
657        );
658
659        assert!(inv.contains("py:function", "test_func"));
660        assert_eq!(inv.get("py:function", "test_func"), Some(&item));
661        assert!(!inv.contains("py:function", "nonexistent"));
662    }
663
664    #[test]
665    fn test_escape_string() {
666        assert_eq!(
667            InventoryFile::escape_string("test   multiple   spaces"),
668            "test multiple spaces"
669        );
670        assert_eq!(InventoryFile::escape_string("test\ttab"), "test tab");
671        assert_eq!(
672            InventoryFile::escape_string("test\nnewline"),
673            "test newline"
674        );
675    }
676
677    // -- posix_join: mirrors posixpath.join(uri, location), verified against
678    // real `posixpath.join` output for every case below. --
679
680    #[test]
681    fn test_posix_join_inserts_separator() {
682        assert_eq!(posix_join("/util", "foo.html"), "/util/foo.html");
683    }
684
685    #[test]
686    fn test_posix_join_no_double_separator() {
687        assert_eq!(posix_join("/util/", "foo.html"), "/util/foo.html");
688    }
689
690    #[test]
691    fn test_posix_join_empty_location() {
692        assert_eq!(posix_join("/util", ""), "/util/");
693    }
694
695    #[test]
696    fn test_posix_join_empty_uri() {
697        assert_eq!(posix_join("", "foo.html"), "foo.html");
698    }
699
700    #[test]
701    fn test_posix_join_absolute_location_overrides_uri() {
702        assert_eq!(posix_join("/util", "/abs/path.html"), "/abs/path.html");
703    }
704
705    #[test]
706    fn test_posix_join_both_empty() {
707        assert_eq!(posix_join("", ""), "");
708    }
709
710    #[test]
711    fn test_posix_join_uri_with_scheme() {
712        assert_eq!(
713            posix_join("https://example.org/v1", "sub/x.html#y"),
714            "https://example.org/v1/sub/x.html#y"
715        );
716    }
717
718    // -- python_str_splitlines --
719
720    #[test]
721    fn test_splitlines_mixed_separators() {
722        assert_eq!(
723            python_str_splitlines("a\r\nb\rc\u{0b}d\u{0c}e"),
724            vec!["a", "b", "c", "d", "e"]
725        );
726    }
727
728    #[test]
729    fn test_splitlines_no_trailing_empty() {
730        assert_eq!(python_str_splitlines("a\nb\n"), vec!["a", "b"]);
731    }
732
733    #[test]
734    fn test_splitlines_empty_string() {
735        assert!(python_str_splitlines("").is_empty());
736    }
737
738    #[test]
739    fn test_splitlines_lone_newline() {
740        assert_eq!(python_str_splitlines("\n"), vec![""]);
741    }
742
743    #[test]
744    fn test_splitlines_embedded_blank_line() {
745        assert_eq!(python_str_splitlines("a\n\nb"), vec!["a", "", "b"]);
746    }
747
748    // -- python_split_none_maxsplit --
749
750    #[test]
751    fn test_split_none_maxsplit_collapses_runs() {
752        assert_eq!(
753            python_split_none_maxsplit("module   mod    foo.html", 2),
754            vec!["module", "mod", "foo.html"]
755        );
756    }
757
758    #[test]
759    fn test_split_none_maxsplit_remainder_keeps_internal_whitespace() {
760        assert_eq!(
761            python_split_none_maxsplit("a b c d e", 2),
762            vec!["a", "b", "c d e"]
763        );
764    }
765
766    #[test]
767    fn test_split_none_maxsplit_empty() {
768        assert!(python_split_none_maxsplit("", 2).is_empty());
769        assert!(python_split_none_maxsplit("   ", 2).is_empty());
770    }
771
772    #[test]
773    fn test_split_none_maxsplit_too_few_tokens() {
774        assert_eq!(python_split_none_maxsplit("onlyone", 2), vec!["onlyone"]);
775    }
776
777    // -- python_repr_str --
778
779    #[test]
780    fn test_python_repr_str_plain() {
781        assert_eq!(python_repr_str("5"), "'5'");
782    }
783
784    #[test]
785    fn test_python_repr_str_prefers_single_quotes() {
786        assert_eq!(python_repr_str("2.5-beta"), "'2.5-beta'");
787    }
788
789    #[test]
790    fn test_python_repr_str_switches_to_double_quotes() {
791        assert_eq!(python_repr_str("it's"), "\"it's\"");
792    }
793
794    // -- v2 line regex: no match on a genuinely non-conforming line (must
795    // be silently skippable by the caller, never fall back to a looser
796    // split) --
797
798    #[test]
799    fn test_v2_line_regex_no_match_on_garbage() {
800        // No `-?\d+` priority field anywhere in this line, so no match can
801        // exist at any start position (with or without the `^` anchor).
802        assert!(V2_LINE_RE
803            .captures("not a valid entry line at all")
804            .is_none());
805    }
806
807    #[test]
808    fn test_v2_line_regex_captures_five_groups() {
809        let caps = V2_LINE_RE
810            .captures("a term including:colon std:term -1 glossary.html#term -")
811            .unwrap();
812        assert_eq!(&caps[1], "a term including:colon");
813        assert_eq!(&caps[2], "std:term");
814        assert_eq!(&caps[3], "-1");
815        assert_eq!(&caps[4], "glossary.html#term");
816        assert_eq!(&caps[5], "-");
817    }
818}