sphinx_ultra/
inventory.rs

1use anyhow::{Context, Result};
2use flate2::write::ZlibEncoder;
3use flate2::Compression;
4use log::info;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::io::Write;
8use std::path::Path;
9use tokio::fs;
10
11/// Inventory item representing a single object in the documentation
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
13pub struct InventoryItem {
14    pub project_name: String,
15    pub project_version: String,
16    pub uri: String,
17    pub display_name: String,
18}
19
20impl InventoryItem {
21    pub fn new(
22        project_name: String,
23        project_version: String,
24        uri: String,
25        display_name: String,
26    ) -> Self {
27        Self {
28            project_name,
29            project_version,
30            uri,
31            display_name,
32        }
33    }
34}
35
36/// In-memory inventory data structure
37#[derive(Debug, Clone, Default)]
38pub struct Inventory {
39    pub data: HashMap<String, HashMap<String, InventoryItem>>,
40}
41
42impl Inventory {
43    pub fn new() -> Self {
44        Self {
45            data: HashMap::new(),
46        }
47    }
48
49    /// Insert an item into the inventory
50    pub fn insert(&mut self, obj_type: String, name: String, item: InventoryItem) {
51        self.data.entry(obj_type).or_default().insert(name, item);
52    }
53
54    /// Get an item from the inventory
55    pub fn get(&self, obj_type: &str, name: &str) -> Option<&InventoryItem> {
56        self.data.get(obj_type)?.get(name)
57    }
58
59    /// Check if an item exists in the inventory
60    pub fn contains(&self, obj_type: &str, name: &str) -> bool {
61        self.data
62            .get(obj_type)
63            .is_some_and(|objects| objects.contains_key(name))
64    }
65}
66
67/// Inventory file handler - mirrors Sphinx's InventoryFile class
68pub struct InventoryFile;
69
70impl InventoryFile {
71    /// Load inventory from bytes (mirrors Sphinx's loads method)
72    pub fn loads(content: &[u8], uri: &str) -> Result<Inventory> {
73        let content_str = String::from_utf8_lossy(content);
74        let mut lines = content_str.lines();
75
76        // Parse header
77        let format_line = lines.next().unwrap_or("").trim();
78
79        if format_line == "# Sphinx inventory version 2" {
80            Self::loads_v2(&mut lines, uri)
81        } else if format_line == "# Sphinx inventory version 1" {
82            Self::loads_v1(&mut lines, uri)
83        } else if let Some(version) = format_line.strip_prefix("# Sphinx inventory version ") {
84            anyhow::bail!("Unknown or unsupported inventory version: {}", version);
85        } else {
86            anyhow::bail!("Invalid inventory header: {}", format_line);
87        }
88    }
89
90    /// Load inventory from version 1 format
91    fn loads_v1(lines: &mut std::str::Lines, uri: &str) -> Result<Inventory> {
92        let mut inv = Inventory::new();
93
94        let project_line = lines
95            .next()
96            .ok_or_else(|| anyhow::anyhow!("Missing project name"))?;
97        let version_line = lines
98            .next()
99            .ok_or_else(|| anyhow::anyhow!("Missing project version"))?;
100
101        if !project_line.starts_with("# Project: ") || !version_line.starts_with("# Version: ") {
102            anyhow::bail!("Invalid inventory header: missing project name or version");
103        }
104
105        let project_name = project_line[11..].trim();
106        let version = version_line[11..].trim();
107
108        for line in lines {
109            let line = line.trim();
110            if line.is_empty() || line.starts_with('#') {
111                continue;
112            }
113
114            let parts: Vec<&str> = line.splitn(3, ' ').collect();
115            if parts.len() != 3 {
116                continue;
117            }
118
119            let name = parts[0];
120            let item_type = parts[1];
121            let location = parts[2];
122
123            let full_location = if uri.is_empty() {
124                location.to_string()
125            } else {
126                format!("{}/{}", uri.trim_end_matches('/'), location)
127            };
128
129            // Version 1 format conversion
130            let (domain_type, anchor) = if item_type == "mod" {
131                ("py:module".to_string(), format!("#module-{}", name))
132            } else {
133                (format!("py:{}", item_type), format!("#{}", name))
134            };
135
136            let item = InventoryItem::new(
137                project_name.to_string(),
138                version.to_string(),
139                format!("{}{}", full_location, anchor),
140                "-".to_string(),
141            );
142
143            inv.insert(domain_type, name.to_string(), item);
144        }
145
146        Ok(inv)
147    }
148
149    /// Load inventory from version 2 format
150    fn loads_v2(lines: &mut std::str::Lines, uri: &str) -> Result<Inventory> {
151        let mut inv = Inventory::new();
152
153        let project_line = lines
154            .next()
155            .ok_or_else(|| anyhow::anyhow!("Missing project name"))?;
156        let version_line = lines
157            .next()
158            .ok_or_else(|| anyhow::anyhow!("Missing project version"))?;
159        let compression_line = lines
160            .next()
161            .ok_or_else(|| anyhow::anyhow!("Missing compression info"))?;
162
163        if !project_line.starts_with("# Project: ") || !version_line.starts_with("# Version: ") {
164            anyhow::bail!("Invalid inventory header: missing project name or version");
165        }
166
167        let project_name = project_line[11..].trim();
168        let version = version_line[11..].trim();
169
170        if !compression_line.contains("zlib") {
171            anyhow::bail!(
172                "Invalid inventory header (not compressed): {}",
173                compression_line
174            );
175        }
176
177        // Read the rest as compressed data
178        let remaining_content: String = lines.collect::<Vec<_>>().join("\n");
179        let compressed_data = {
180            use base64::prelude::*;
181            BASE64_STANDARD.decode(&remaining_content).or_else(|_| {
182                // If base64 decode fails, try treating as raw bytes
183                Ok::<Vec<u8>, base64::DecodeError>(remaining_content.as_bytes().to_vec())
184            })?
185        };
186
187        // Decompress using zlib
188        let decompressed = Self::decompress_zlib(&compressed_data)?;
189        let decompressed_str = String::from_utf8(decompressed)?;
190
191        // Parse inventory entries
192        for line in decompressed_str.lines() {
193            let line = line.trim();
194            if line.is_empty() {
195                continue;
196            }
197
198            // Parse: name type priority location display_name
199            let parts = Self::parse_inventory_line(line);
200            if parts.len() != 5 {
201                continue;
202            }
203
204            let name = parts[0];
205            let obj_type = parts[1];
206            let _priority = parts[2];
207            let mut location = parts[3].to_string();
208            let display_name = parts[4];
209
210            // Skip invalid types
211            if !obj_type.contains(':') {
212                continue;
213            }
214
215            // Handle location anchors
216            if location.ends_with('$') {
217                location = location[..location.len() - 1].to_string() + name;
218            }
219
220            let full_location = if uri.is_empty() {
221                location
222            } else {
223                format!("{}/{}", uri.trim_end_matches('/'), location)
224            };
225
226            let display_name = if display_name == "-" {
227                name.to_string()
228            } else {
229                display_name.to_string()
230            };
231
232            let item = InventoryItem::new(
233                project_name.to_string(),
234                version.to_string(),
235                full_location,
236                display_name,
237            );
238
239            inv.insert(obj_type.to_string(), name.to_string(), item);
240        }
241
242        Ok(inv)
243    }
244
245    /// Parse a single inventory line, handling embedded spaces
246    fn parse_inventory_line(line: &str) -> Vec<&str> {
247        let regex = regex::Regex::new(r"(.+?)\s+(\S+)\s+(-?\d+)\s+?(\S*)\s+(.*)").unwrap();
248
249        if let Some(captures) = regex.captures(line) {
250            vec![
251                captures.get(1).map_or("", |m| m.as_str()),
252                captures.get(2).map_or("", |m| m.as_str()),
253                captures.get(3).map_or("", |m| m.as_str()),
254                captures.get(4).map_or("", |m| m.as_str()),
255                captures.get(5).map_or("", |m| m.as_str()),
256            ]
257        } else {
258            line.split_whitespace().collect()
259        }
260    }
261
262    /// Decompress zlib data
263    fn decompress_zlib(data: &[u8]) -> Result<Vec<u8>> {
264        use flate2::read::ZlibDecoder;
265        use std::io::Read;
266
267        let mut decoder = ZlibDecoder::new(data);
268        let mut decompressed = Vec::new();
269        decoder.read_to_end(&mut decompressed)?;
270        Ok(decompressed)
271    }
272
273    /// Dump inventory to file (mirrors Sphinx's dump method)
274    pub async fn dump<P: AsRef<Path>>(
275        filename: P,
276        env: &crate::environment::BuildEnvironment,
277        builder: &crate::html_builder::HTMLBuilder,
278    ) -> Result<()> {
279        info!("Dumping inventory to {}", filename.as_ref().display());
280
281        let mut content = Vec::new();
282
283        // Write header
284        let project = &env.config.project;
285        let version = env.config.version.as_deref().unwrap_or("");
286
287        let header = format!(
288            "# Sphinx inventory version 2\n# Project: {}\n# Version: {}\n# The remainder of this file is compressed using zlib.\n",
289            Self::escape_string(project),
290            Self::escape_string(version)
291        );
292        content.extend_from_slice(header.as_bytes());
293
294        // Prepare inventory data
295        let mut inventory_lines = Vec::new();
296
297        // Collect all objects from all domains
298        for (domain_name, domain) in &env.domains {
299            let objects = domain.get_objects();
300            for object in objects {
301                let fullname = &object.name;
302                let dispname = object.display_name.as_deref().unwrap_or(fullname);
303                let obj_type = &object.object_type;
304                let docname = &object.docname;
305                let anchor = object.anchor.as_deref().unwrap_or("");
306                let priority = object.priority;
307
308                // Build URI
309                let mut uri = builder.get_target_uri(docname);
310                if !anchor.is_empty() {
311                    if anchor.ends_with(fullname) {
312                        // Optimize by using $ suffix
313                        let prefix = &anchor[..anchor.len() - fullname.len()];
314                        uri = format!("{}{}$", uri, prefix);
315                    } else {
316                        uri = format!("{}#{}", uri, anchor);
317                    }
318                }
319
320                let final_dispname = if dispname == fullname { "-" } else { dispname };
321
322                let entry = format!(
323                    "{} {}:{} {} {} {}\n",
324                    fullname, domain_name, obj_type, priority, uri, final_dispname
325                );
326                inventory_lines.push(entry);
327            }
328        }
329
330        // Sort entries for consistency
331        inventory_lines.sort();
332
333        // Compress the inventory data
334        let inventory_data = inventory_lines.join("");
335        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
336        encoder.write_all(inventory_data.as_bytes())?;
337        let compressed_data = encoder.finish()?;
338
339        content.extend_from_slice(&compressed_data);
340
341        // Write to file
342        fs::write(filename, content)
343            .await
344            .context("Failed to write inventory file")?;
345
346        Ok(())
347    }
348
349    /// Escape string for inventory header
350    fn escape_string(s: &str) -> String {
351        regex::Regex::new(r"\s+")
352            .unwrap()
353            .replace_all(s, " ")
354            .to_string()
355    }
356
357    /// Load inventory from file
358    pub async fn load<P: AsRef<Path>>(filename: P, uri: &str) -> Result<Inventory> {
359        let content = fs::read(filename.as_ref()).await.with_context(|| {
360            format!(
361                "Failed to read inventory file: {}",
362                filename.as_ref().display()
363            )
364        })?;
365
366        Self::loads(&content, uri)
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_inventory_item_creation() {
376        let item = InventoryItem::new(
377            "test_project".to_string(),
378            "1.0".to_string(),
379            "http://example.com/test.html".to_string(),
380            "Test Item".to_string(),
381        );
382
383        assert_eq!(item.project_name, "test_project");
384        assert_eq!(item.project_version, "1.0");
385        assert_eq!(item.uri, "http://example.com/test.html");
386        assert_eq!(item.display_name, "Test Item");
387    }
388
389    #[test]
390    fn test_inventory_operations() {
391        let mut inv = Inventory::new();
392
393        let item = InventoryItem::new(
394            "test".to_string(),
395            "1.0".to_string(),
396            "test.html".to_string(),
397            "Test".to_string(),
398        );
399
400        inv.insert(
401            "py:function".to_string(),
402            "test_func".to_string(),
403            item.clone(),
404        );
405
406        assert!(inv.contains("py:function", "test_func"));
407        assert_eq!(inv.get("py:function", "test_func"), Some(&item));
408        assert!(!inv.contains("py:function", "nonexistent"));
409    }
410
411    #[tokio::test]
412    async fn test_parse_inventory_line() {
413        let line = "test_function py:function 1 module.html#test_function Test Function";
414        let parts = InventoryFile::parse_inventory_line(line);
415
416        assert_eq!(parts.len(), 5);
417        assert_eq!(parts[0], "test_function");
418        assert_eq!(parts[1], "py:function");
419        assert_eq!(parts[2], "1");
420        assert_eq!(parts[3], "module.html#test_function");
421        assert_eq!(parts[4], "Test Function");
422    }
423
424    #[test]
425    fn test_escape_string() {
426        assert_eq!(
427            InventoryFile::escape_string("test   multiple   spaces"),
428            "test multiple spaces"
429        );
430        assert_eq!(InventoryFile::escape_string("test\ttab"), "test tab");
431        assert_eq!(
432            InventoryFile::escape_string("test\nnewline"),
433            "test newline"
434        );
435    }
436}