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#[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#[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 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 pub fn get(&self, obj_type: &str, name: &str) -> Option<&InventoryItem> {
56 self.data.get(obj_type)?.get(name)
57 }
58
59 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
67pub struct InventoryFile;
69
70impl InventoryFile {
71 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 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 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 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 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 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 Ok::<Vec<u8>, base64::DecodeError>(remaining_content.as_bytes().to_vec())
184 })?
185 };
186
187 let decompressed = Self::decompress_zlib(&compressed_data)?;
189 let decompressed_str = String::from_utf8(decompressed)?;
190
191 for line in decompressed_str.lines() {
193 let line = line.trim();
194 if line.is_empty() {
195 continue;
196 }
197
198 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 if !obj_type.contains(':') {
212 continue;
213 }
214
215 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 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 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 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 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 let mut inventory_lines = Vec::new();
296
297 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 let mut uri = builder.get_target_uri(docname);
310 if !anchor.is_empty() {
311 if anchor.ends_with(fullname) {
312 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 inventory_lines.sort();
332
333 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 fs::write(filename, content)
343 .await
344 .context("Failed to write inventory file")?;
345
346 Ok(())
347 }
348
349 fn escape_string(s: &str) -> String {
351 regex::Regex::new(r"\s+")
352 .unwrap()
353 .replace_all(s, " ")
354 .to_string()
355 }
356
357 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}