blz_core/
storage.rs

1use crate::{Error, Flavor, LlmsJson, Result, Source};
2use chrono::Utc;
3use directories::ProjectDirs;
4use std::fs;
5use std::path::{Path, PathBuf};
6use tracing::{debug, info, warn};
7
8/// Maximum allowed alias length to match CLI constraints
9const MAX_ALIAS_LEN: usize = 64;
10
11/// Local filesystem storage for cached llms.txt documentation
12pub struct Storage {
13    root_dir: PathBuf,
14}
15
16impl Storage {
17    fn sanitize_variant_file_name(name: &str) -> String {
18        // Only allow a conservative set of filename characters to avoid
19        // accidentally writing outside the tool directory or producing
20        // surprising paths. Anything else becomes an underscore so that the
21        // resulting filename stays predictable and safe to use across
22        // platforms.
23        let mut sanitized: String = name
24            .chars()
25            .map(|c| {
26                if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
27                    c
28                } else {
29                    '_'
30                }
31            })
32            .collect();
33
34        // Collapse any ".." segments that could be introduced either by the
35        // caller or by the substitution above. This keeps the path rooted at
36        // the alias directory even if callers pass traversal attempts.
37        while sanitized.contains("..") {
38            sanitized = sanitized.replace("..", "_");
39        }
40
41        if sanitized.is_empty() {
42            "llms.txt".to_string()
43        } else {
44            sanitized
45        }
46    }
47
48    fn flavor_file_name(flavor: &str) -> String {
49        if flavor.eq_ignore_ascii_case("llms") {
50            "llms.txt".to_string()
51        } else {
52            format!("{flavor}.txt")
53        }
54    }
55
56    fn flavor_json_filename(flavor: &str) -> String {
57        if flavor.eq_ignore_ascii_case("llms") {
58            "llms.json".to_string()
59        } else {
60            format!("{flavor}.json")
61        }
62    }
63
64    fn flavor_metadata_filename(flavor: &str) -> String {
65        if flavor.eq_ignore_ascii_case("llms") {
66            "metadata.json".to_string()
67        } else {
68            format!("metadata-{flavor}.json")
69        }
70    }
71
72    /// Determine the appropriate flavor based on the requested URL.
73    pub fn flavor_from_url(url: &str) -> Flavor {
74        url.rsplit('/')
75            .next()
76            .and_then(Flavor::from_file_name)
77            .unwrap_or(Flavor::Llms)
78    }
79
80    /// Creates a new storage instance with the default root directory
81    pub fn new() -> Result<Self> {
82        // Test/dev override: allow BLZ_DATA_DIR to set the root directory explicitly
83        if let Ok(dir) = std::env::var("BLZ_DATA_DIR") {
84            let root = PathBuf::from(dir);
85            return Self::with_root(root);
86        }
87
88        let project_dirs = ProjectDirs::from("dev", "outfitter", "blz")
89            .ok_or_else(|| Error::Storage("Failed to determine project directories".into()))?;
90
91        let root_dir = project_dirs.data_dir().to_path_buf();
92
93        // Check for migration from old cache directory
94        Self::check_and_migrate_old_cache(&root_dir);
95
96        Self::with_root(root_dir)
97    }
98
99    /// Creates a new storage instance with a custom root directory
100    pub fn with_root(root_dir: PathBuf) -> Result<Self> {
101        fs::create_dir_all(&root_dir)
102            .map_err(|e| Error::Storage(format!("Failed to create root directory: {e}")))?;
103
104        Ok(Self { root_dir })
105    }
106
107    /// Returns the directory path for a given alias
108    pub fn tool_dir(&self, alias: &str) -> Result<PathBuf> {
109        // Validate alias to prevent directory traversal attacks
110        Self::validate_alias(alias)?;
111        Ok(self.root_dir.join(alias))
112    }
113
114    /// Resolve the on-disk path for a specific flavored content file.
115    fn variant_file_path(&self, alias: &str, file_name: &str) -> Result<PathBuf> {
116        let sanitized = Self::sanitize_variant_file_name(file_name);
117        Ok(self.tool_dir(alias)?.join(sanitized))
118    }
119
120    /// Ensures the directory for an alias exists and returns its path
121    pub fn ensure_tool_dir(&self, alias: &str) -> Result<PathBuf> {
122        let dir = self.tool_dir(alias)?;
123        fs::create_dir_all(&dir)
124            .map_err(|e| Error::Storage(format!("Failed to create tool directory: {e}")))?;
125        Ok(dir)
126    }
127
128    /// Validate that an alias is safe to use as a directory name
129    ///
130    /// This validation is unified with CLI constraints to prevent inconsistencies
131    /// between what the CLI accepts and what storage can handle.
132    fn validate_alias(alias: &str) -> Result<()> {
133        // Check for empty alias
134        if alias.is_empty() {
135            return Err(Error::Storage("Alias cannot be empty".into()));
136        }
137
138        // Disallow leading hyphen to avoid CLI parsing ambiguities
139        if alias.starts_with('-') {
140            return Err(Error::Storage(format!(
141                "Invalid alias '{alias}': cannot start with '-'"
142            )));
143        }
144
145        // Check for path traversal attempts
146        if alias.contains("..") || alias.contains('/') || alias.contains('\\') {
147            return Err(Error::Storage(format!(
148                "Invalid alias '{alias}': contains path traversal characters"
149            )));
150        }
151
152        // Check for special filesystem characters
153        if alias.starts_with('.') || alias.contains('\0') {
154            return Err(Error::Storage(format!(
155                "Invalid alias '{alias}': contains invalid filesystem characters"
156            )));
157        }
158
159        // Check for reserved names on Windows
160        #[cfg(target_os = "windows")]
161        {
162            const RESERVED_NAMES: &[&str] = &[
163                "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
164                "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
165                "LPT9",
166            ];
167
168            let upper_alias = alias.to_uppercase();
169            if RESERVED_NAMES.contains(&upper_alias.as_str()) {
170                return Err(Error::Storage(format!(
171                    "Invalid alias '{}': reserved name on Windows",
172                    alias
173                )));
174            }
175        }
176
177        // Check length (keep consistent with CLI policy)
178        if alias.len() > MAX_ALIAS_LEN {
179            return Err(Error::Storage(format!(
180                "Invalid alias '{alias}': exceeds maximum length of {MAX_ALIAS_LEN} characters"
181            )));
182        }
183
184        // Only allow ASCII alphanumeric, dash, underscore
185        if !alias
186            .chars()
187            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
188        {
189            return Err(Error::Storage(format!(
190                "Invalid alias '{alias}': only [A-Za-z0-9_-] are allowed"
191            )));
192        }
193
194        Ok(())
195    }
196
197    /// Returns the path to the llms.txt file for an alias
198    pub fn llms_txt_path(&self, alias: &str) -> Result<PathBuf> {
199        self.variant_file_path(alias, "llms.txt")
200    }
201
202    /// Returns the path to the llms.json file for an alias
203    pub fn llms_json_path(&self, alias: &str) -> Result<PathBuf> {
204        self.flavor_json_path(alias, "llms")
205    }
206
207    /// Compute the metadata JSON path for a given flavor.
208    pub fn flavor_json_path(&self, alias: &str, flavor: &str) -> Result<PathBuf> {
209        let file = Self::flavor_json_filename(flavor);
210        Ok(self.tool_dir(alias)?.join(file))
211    }
212
213    /// Returns the path to the search index directory for an alias
214    pub fn index_dir(&self, alias: &str) -> Result<PathBuf> {
215        Ok(self.tool_dir(alias)?.join(".index"))
216    }
217
218    /// Returns the path to the archive directory for an alias
219    pub fn archive_dir(&self, alias: &str) -> Result<PathBuf> {
220        Ok(self.tool_dir(alias)?.join(".archive"))
221    }
222
223    /// Returns the path to the metadata file for an alias
224    pub fn metadata_path(&self, alias: &str) -> Result<PathBuf> {
225        self.metadata_path_for_flavor(alias, "llms")
226    }
227
228    /// Compute the metadata path for a given flavor.
229    pub fn metadata_path_for_flavor(&self, alias: &str, flavor: &str) -> Result<PathBuf> {
230        let file = Self::flavor_metadata_filename(flavor);
231        Ok(self.tool_dir(alias)?.join(file))
232    }
233
234    /// Returns the path to the anchors mapping file for an alias
235    pub fn anchors_map_path(&self, alias: &str) -> Result<PathBuf> {
236        Ok(self.tool_dir(alias)?.join("anchors.json"))
237    }
238
239    /// Saves the llms.txt content for an alias
240    pub fn save_llms_txt(&self, alias: &str, content: &str) -> Result<()> {
241        self.save_flavor_content(alias, "llms.txt", content)
242    }
243
244    /// Saves content for a specific flavored variant (e.g., llms-full.txt)
245    pub fn save_flavor_content(&self, alias: &str, file_name: &str, content: &str) -> Result<()> {
246        self.ensure_tool_dir(alias)?;
247        let path = self.variant_file_path(alias, file_name)?;
248
249        let tmp_path = path.with_extension("tmp");
250        fs::write(&tmp_path, content)
251            .map_err(|e| Error::Storage(format!("Failed to write {file_name}: {e}")))?;
252
253        #[cfg(target_os = "windows")]
254        if path.exists() {
255            fs::remove_file(&path).map_err(|e| {
256                Error::Storage(format!("Failed to remove existing {file_name}: {e}"))
257            })?;
258        }
259
260        fs::rename(&tmp_path, &path)
261            .map_err(|e| Error::Storage(format!("Failed to commit {file_name}: {e}")))?;
262
263        debug!("Saved {file_name} for {}", alias);
264        Ok(())
265    }
266
267    /// Returns the path to the persisted text content for the given flavor.
268    pub fn flavor_file_path(&self, alias: &str, flavor: &str) -> Result<PathBuf> {
269        let file_name = Self::flavor_file_name(flavor);
270        self.variant_file_path(alias, &file_name)
271    }
272
273    /// Loads the llms.txt content for an alias
274    pub fn load_llms_txt(&self, alias: &str) -> Result<String> {
275        let path = self.llms_txt_path(alias)?;
276        fs::read_to_string(&path)
277            .map_err(|e| Error::Storage(format!("Failed to read llms.txt: {e}")))
278    }
279
280    /// Saves the parsed llms.json data for the default flavor
281    pub fn save_llms_json(&self, alias: &str, data: &LlmsJson) -> Result<()> {
282        self.save_flavor_json(alias, "llms", data)
283    }
284
285    /// Saves the parsed llms.json data for a specific flavor
286    pub fn save_flavor_json(&self, alias: &str, flavor: &str, data: &LlmsJson) -> Result<()> {
287        self.ensure_tool_dir(alias)?;
288        let path = self.flavor_json_path(alias, flavor)?;
289        let json = serde_json::to_string_pretty(data)
290            .map_err(|e| Error::Storage(format!("Failed to serialize JSON: {e}")))?;
291
292        let tmp_path = path.with_extension("json.tmp");
293        fs::write(&tmp_path, json)
294            .map_err(|e| Error::Storage(format!("Failed to write {flavor} metadata: {e}")))?;
295
296        #[cfg(target_os = "windows")]
297        if path.exists() {
298            fs::remove_file(&path).map_err(|e| {
299                Error::Storage(format!("Failed to remove existing {flavor} metadata: {e}"))
300            })?;
301        }
302        fs::rename(&tmp_path, &path)
303            .map_err(|e| Error::Storage(format!("Failed to commit {flavor} metadata: {e}")))?;
304
305        debug!("Saved {flavor} metadata for {}", alias);
306        Ok(())
307    }
308
309    /// Loads the parsed llms.json data for the default flavor
310    pub fn load_llms_json(&self, alias: &str) -> Result<LlmsJson> {
311        self.load_flavor_json(alias, "llms").and_then(|opt| {
312            opt.ok_or_else(|| Error::Storage(format!("llms.json missing for alias '{alias}'")))
313        })
314    }
315
316    /// Loads the parsed llms.json data for a specific flavor, returning None if absent
317    pub fn load_flavor_json(&self, alias: &str, flavor: &str) -> Result<Option<LlmsJson>> {
318        let path = self.flavor_json_path(alias, flavor)?;
319        if !path.exists() {
320            return Ok(None);
321        }
322        let json = fs::read_to_string(&path)
323            .map_err(|e| Error::Storage(format!("Failed to read {}: {e}", path.display())))?;
324        let data = serde_json::from_str(&json)
325            .map_err(|e| Error::Storage(format!("Failed to parse JSON: {e}")))?;
326        Ok(Some(data))
327    }
328
329    /// Saves source metadata for an alias
330    pub fn save_source_metadata(&self, alias: &str, source: &Source) -> Result<()> {
331        self.save_source_metadata_for_flavor(alias, "llms", source)
332    }
333
334    /// Persist source metadata for a specific flavor.
335    pub fn save_source_metadata_for_flavor(
336        &self,
337        alias: &str,
338        flavor: &str,
339        source: &Source,
340    ) -> Result<()> {
341        self.ensure_tool_dir(alias)?;
342        let path = self.metadata_path_for_flavor(alias, flavor)?;
343        let json = serde_json::to_string_pretty(source)
344            .map_err(|e| Error::Storage(format!("Failed to serialize metadata: {e}")))?;
345
346        // Write to a temp file first to ensure atomicity
347        let tmp_path = path.with_extension("json.tmp");
348        fs::write(&tmp_path, &json)
349            .map_err(|e| Error::Storage(format!("Failed to write temp metadata: {e}")))?;
350
351        // Atomically rename temp file to final path (handle Windows overwrite)
352        #[cfg(target_os = "windows")]
353        if path.exists() {
354            fs::remove_file(&path)
355                .map_err(|e| Error::Storage(format!("Failed to remove existing metadata: {e}")))?;
356        }
357        fs::rename(&tmp_path, &path)
358            .map_err(|e| Error::Storage(format!("Failed to persist metadata: {e}")))?;
359
360        debug!("Saved {flavor} metadata for {}", alias);
361        Ok(())
362    }
363
364    /// Save anchors remap JSON for an alias
365    pub fn save_anchors_map(&self, alias: &str, map: &crate::AnchorsMap) -> Result<()> {
366        self.ensure_tool_dir(alias)?;
367        let path = self.anchors_map_path(alias)?;
368        let json = serde_json::to_string_pretty(map)
369            .map_err(|e| Error::Storage(format!("Failed to serialize anchors map: {e}")))?;
370        fs::write(&path, json)
371            .map_err(|e| Error::Storage(format!("Failed to write anchors map: {e}")))?;
372        Ok(())
373    }
374
375    /// Loads source metadata for an alias if it exists
376    pub fn load_source_metadata(&self, alias: &str) -> Result<Option<Source>> {
377        self.load_source_metadata_for_flavor(alias, "llms")
378    }
379
380    /// Load source metadata for a specific flavor if present.
381    pub fn load_source_metadata_for_flavor(
382        &self,
383        alias: &str,
384        flavor: &str,
385    ) -> Result<Option<Source>> {
386        let path = self.metadata_path_for_flavor(alias, flavor)?;
387        if !path.exists() {
388            return Ok(None);
389        }
390        let json = fs::read_to_string(&path)
391            .map_err(|e| Error::Storage(format!("Failed to read metadata: {e}")))?;
392        let source = serde_json::from_str(&json)
393            .map_err(|e| Error::Storage(format!("Failed to parse metadata: {e}")))?;
394        Ok(Some(source))
395    }
396
397    /// Checks if an alias exists in storage
398    #[must_use]
399    pub fn exists(&self, alias: &str) -> bool {
400        self.llms_json_path(alias)
401            .map(|path| path.exists())
402            .unwrap_or(false)
403    }
404
405    /// Checks if any flavor has been persisted for the alias.
406    #[must_use]
407    pub fn exists_any_flavor(&self, alias: &str) -> bool {
408        if self.exists(alias) {
409            return true;
410        }
411
412        self.available_flavors(alias)
413            .map(|flavors| !flavors.is_empty())
414            .unwrap_or(false)
415    }
416
417    /// Lists all available documentation flavors persisted for a given alias.
418    ///
419    /// Flavors correspond to the JSON artifacts produced during ingest, e.g.
420    /// `llms.json` and `llms-full.json`. Metadata sidecars (like
421    /// `metadata.json`) and other auxiliary files are excluded.
422    pub fn available_flavors(&self, alias: &str) -> Result<Vec<String>> {
423        let dir = self.tool_dir(alias)?;
424        if !dir.exists() {
425            return Ok(Vec::new());
426        }
427
428        let mut flavors = Vec::new();
429        let entries = fs::read_dir(&dir)
430            .map_err(|e| Error::Storage(format!("Failed to read tool directory: {e}")))?;
431
432        for entry in entries {
433            let entry = entry
434                .map_err(|e| Error::Storage(format!("Failed to read directory entry: {e}")))?;
435            let path = entry.path();
436
437            if !path.is_file() {
438                continue;
439            }
440
441            if !path
442                .extension()
443                .and_then(|ext| ext.to_str())
444                .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
445            {
446                continue;
447            }
448
449            if let (Some(stem), Some(ext)) = (
450                path.file_stem().and_then(|s| s.to_str()),
451                path.extension().and_then(|s| s.to_str()),
452            ) {
453                if !ext.eq_ignore_ascii_case("json") {
454                    continue;
455                }
456
457                // Only include llms*.json artifacts (e.g., llms.json, llms-full.json)
458                let stem_lower = stem.trim().to_ascii_lowercase();
459                if stem_lower == "llms" || stem_lower.starts_with("llms-") {
460                    flavors.push(stem_lower);
461                }
462            }
463        }
464
465        flavors.sort();
466        flavors.dedup();
467        Ok(flavors)
468    }
469
470    /// Lists all cached source aliases
471    #[must_use]
472    pub fn list_sources(&self) -> Vec<String> {
473        let mut sources = Vec::new();
474
475        if let Ok(entries) = fs::read_dir(&self.root_dir) {
476            for entry in entries.flatten() {
477                if entry.path().is_dir() {
478                    if let Some(name) = entry.file_name().to_str() {
479                        if !name.starts_with('.') && self.exists_any_flavor(name) {
480                            sources.push(name.to_string());
481                        }
482                    }
483                }
484            }
485        }
486
487        sources.sort();
488        sources
489    }
490
491    /// Archives the current version of an alias
492    pub fn archive(&self, alias: &str) -> Result<()> {
493        let archive_dir = self.archive_dir(alias)?;
494        fs::create_dir_all(&archive_dir)
495            .map_err(|e| Error::Storage(format!("Failed to create archive directory: {e}")))?;
496
497        // Include seconds for uniqueness and clearer chronology
498        let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%SZ");
499
500        // Archive all llms*.json and llms*.txt files (multi-flavor support)
501        let dir = self.tool_dir(alias)?;
502        if dir.exists() {
503            for entry in fs::read_dir(&dir)
504                .map_err(|e| Error::Storage(format!("Failed to read dir for archive: {e}")))?
505            {
506                let entry =
507                    entry.map_err(|e| Error::Storage(format!("Failed to read entry: {e}")))?;
508                let path = entry.path();
509                if !path.is_file() {
510                    continue;
511                }
512                let name = entry.file_name();
513                let name_str = name.to_string_lossy().to_lowercase();
514                // Archive only llms*.json / llms*.txt (skip metadata/anchors)
515                let is_json = std::path::Path::new(&name_str)
516                    .extension()
517                    .is_some_and(|ext| ext.eq_ignore_ascii_case("json"));
518                let is_txt = std::path::Path::new(&name_str)
519                    .extension()
520                    .is_some_and(|ext| ext.eq_ignore_ascii_case("txt"));
521                let is_llms_artifact = (is_json || is_txt) && name_str.starts_with("llms");
522                if is_llms_artifact {
523                    let archive_path =
524                        archive_dir.join(format!("{timestamp}-{}", name.to_string_lossy()));
525                    fs::copy(&path, &archive_path).map_err(|e| {
526                        Error::Storage(format!("Failed to archive {}: {e}", path.display()))
527                    })?;
528                }
529            }
530        }
531
532        info!("Archived {} at {}", alias, timestamp);
533        Ok(())
534    }
535
536    /// Check for old cache directory and migrate if needed
537    fn check_and_migrate_old_cache(new_root: &Path) {
538        // Try to find the old cache directory
539        let old_project_dirs = ProjectDirs::from("dev", "outfitter", "cache");
540
541        if let Some(old_dirs) = old_project_dirs {
542            let old_root = old_dirs.data_dir();
543
544            // Check if old directory exists and has content
545            if old_root.exists() && old_root.is_dir() {
546                // Check if there's actually content to migrate (look for llms.json files)
547                let has_content = fs::read_dir(old_root)
548                    .map(|entries| {
549                        entries.filter_map(std::result::Result::ok).any(|entry| {
550                            let path = entry.path();
551                            if !path.is_dir() {
552                                return false;
553                            }
554                            let has_llms_json = path.join("llms.json").exists();
555                            let has_llms_txt = path.join("llms.txt").exists();
556                            let has_metadata = path.join("metadata.json").exists();
557                            has_llms_json || has_llms_txt || has_metadata
558                        })
559                    })
560                    .unwrap_or(false);
561                if has_content {
562                    // Check if new directory already exists with content
563                    if new_root.exists()
564                        && fs::read_dir(new_root)
565                            .map(|mut e| e.next().is_some())
566                            .unwrap_or(false)
567                    {
568                        // New directory already has content, just log a warning
569                        warn!(
570                            "Found old cache at {} but new cache at {} already exists. \
571                             Manual migration may be needed if you want to preserve old data.",
572                            old_root.display(),
573                            new_root.display()
574                        );
575                    } else {
576                        // Attempt migration
577                        info!(
578                            "Migrating cache from old location {} to new location {}",
579                            old_root.display(),
580                            new_root.display()
581                        );
582
583                        if let Err(e) = Self::migrate_directory(old_root, new_root) {
584                            // Log warning but don't fail - let the user continue with fresh cache
585                            warn!(
586                                "Could not automatically migrate cache: {}. \
587                                 Starting with fresh cache at {}. \
588                                 To manually migrate, copy contents from {} to {}",
589                                e,
590                                new_root.display(),
591                                old_root.display(),
592                                new_root.display()
593                            );
594                        } else {
595                            info!("Successfully migrated cache to new location");
596                        }
597                    }
598                }
599            }
600        }
601    }
602
603    /// Recursively copy directory contents from old to new location
604    fn migrate_directory(from: &Path, to: &Path) -> Result<()> {
605        // Create target directory if it doesn't exist
606        fs::create_dir_all(to)
607            .map_err(|e| Error::Storage(format!("Failed to create migration target: {e}")))?;
608
609        // Copy all entries
610        for entry in fs::read_dir(from)
611            .map_err(|e| Error::Storage(format!("Failed to read migration source: {e}")))?
612        {
613            let entry = entry
614                .map_err(|e| Error::Storage(format!("Failed to read directory entry: {e}")))?;
615            let path = entry.path();
616            let file_name = entry.file_name();
617            let target_path = to.join(&file_name);
618
619            if path.is_dir() {
620                // Recursively copy subdirectory
621                Self::migrate_directory(&path, &target_path)?;
622            } else {
623                // Copy file
624                fs::copy(&path, &target_path).map_err(|e| {
625                    Error::Storage(format!("Failed to copy file during migration: {e}"))
626                })?;
627            }
628        }
629
630        Ok(())
631    }
632}
633
634// Note: Default is not implemented as Storage::new() can fail.
635// Use Storage::new() directly and handle the Result.
636
637#[cfg(test)]
638#[allow(clippy::unwrap_used)]
639mod tests {
640    use super::*;
641    use crate::types::{FileInfo, LineIndex, Source, TocEntry};
642    use std::fs;
643    use tempfile::TempDir;
644
645    fn create_test_storage() -> (Storage, TempDir) {
646        let temp_dir = TempDir::new().expect("Failed to create temp directory");
647        let storage = Storage::with_root(temp_dir.path().to_path_buf())
648            .expect("Failed to create test storage");
649        (storage, temp_dir)
650    }
651
652    fn create_test_llms_json(alias: &str) -> LlmsJson {
653        LlmsJson {
654            alias: alias.to_string(),
655            source: Source {
656                url: format!("https://example.com/{alias}/llms.txt"),
657                etag: Some("abc123".to_string()),
658                last_modified: None,
659                fetched_at: Utc::now(),
660                sha256: "deadbeef".to_string(),
661                aliases: Vec::new(),
662            },
663            toc: vec![TocEntry {
664                heading_path: vec!["Getting Started".to_string()],
665                lines: "1-50".to_string(),
666                anchor: None,
667                children: vec![],
668            }],
669            files: vec![FileInfo {
670                path: "llms.txt".to_string(),
671                sha256: "deadbeef".to_string(),
672            }],
673            line_index: LineIndex {
674                total_lines: 100,
675                byte_offsets: false,
676            },
677            diagnostics: vec![],
678            parse_meta: None,
679        }
680    }
681
682    #[test]
683    fn test_storage_creation_with_root() {
684        let temp_dir = TempDir::new().expect("Failed to create temp directory");
685        let storage = Storage::with_root(temp_dir.path().to_path_buf());
686
687        assert!(storage.is_ok());
688        let _storage = storage.unwrap();
689
690        // Verify root directory was created
691        assert!(temp_dir.path().exists());
692    }
693
694    #[test]
695    fn test_tool_directory_paths() {
696        let (storage, _temp_dir) = create_test_storage();
697
698        let tool_dir = storage.tool_dir("react").expect("Should get tool dir");
699        let llms_txt_path = storage
700            .llms_txt_path("react")
701            .expect("Should get llms.txt path");
702        let llms_json_path = storage
703            .llms_json_path("react")
704            .expect("Should get llms.json path");
705        let index_dir = storage.index_dir("react").expect("Should get index dir");
706        let archive_dir = storage
707            .archive_dir("react")
708            .expect("Should get archive dir");
709
710        assert!(tool_dir.ends_with("react"));
711        assert!(llms_txt_path.ends_with("react/llms.txt"));
712        assert!(llms_json_path.ends_with("react/llms.json"));
713        assert!(index_dir.ends_with("react/.index"));
714        assert!(archive_dir.ends_with("react/.archive"));
715    }
716
717    #[test]
718    fn test_invalid_alias_validation() {
719        let (storage, _temp_dir) = create_test_storage();
720
721        // Test path traversal attempts
722        assert!(storage.tool_dir("../etc").is_err());
723        assert!(storage.tool_dir("../../passwd").is_err());
724        assert!(storage.tool_dir("test/../../../etc").is_err());
725
726        // Test invalid characters
727        assert!(storage.tool_dir(".hidden").is_err());
728        assert!(storage.tool_dir("test\0null").is_err());
729        assert!(storage.tool_dir("test/slash").is_err());
730        assert!(storage.tool_dir("test\\backslash").is_err());
731
732        // Test empty alias
733        assert!(storage.tool_dir("").is_err());
734
735        // Test valid aliases
736        assert!(storage.tool_dir("react").is_ok());
737        assert!(storage.tool_dir("my-tool").is_ok());
738        assert!(storage.tool_dir("tool_123").is_ok());
739    }
740
741    #[test]
742    fn test_ensure_tool_directory() {
743        let (storage, _temp_dir) = create_test_storage();
744
745        let tool_dir = storage
746            .ensure_tool_dir("react")
747            .expect("Should create tool dir");
748        assert!(tool_dir.exists());
749
750        // Should be idempotent
751        let tool_dir2 = storage
752            .ensure_tool_dir("react")
753            .expect("Should not fail on existing dir");
754        assert_eq!(tool_dir, tool_dir2);
755    }
756
757    #[test]
758    fn test_save_and_load_llms_txt() {
759        let (storage, _temp_dir) = create_test_storage();
760
761        let content = "# React Documentation\n\nThis is the React documentation...";
762
763        // Save content
764        storage
765            .save_llms_txt("react", content)
766            .expect("Should save llms.txt");
767
768        // Verify file exists
769        assert!(
770            storage
771                .llms_txt_path("react")
772                .expect("Should get path")
773                .exists()
774        );
775
776        // Load content
777        let loaded_content = storage
778            .load_llms_txt("react")
779            .expect("Should load llms.txt");
780        assert_eq!(content, loaded_content);
781    }
782
783    #[test]
784    fn test_save_and_load_llms_json() {
785        let (storage, _temp_dir) = create_test_storage();
786
787        let llms_json = create_test_llms_json("react");
788
789        // Save JSON
790        storage
791            .save_llms_json("react", &llms_json)
792            .expect("Should save llms.json");
793
794        // Verify file exists
795        assert!(
796            storage
797                .llms_json_path("react")
798                .expect("Should get path")
799                .exists()
800        );
801
802        // Load JSON
803        let loaded_json = storage
804            .load_llms_json("react")
805            .expect("Should load llms.json");
806        assert_eq!(llms_json.alias, loaded_json.alias);
807        assert_eq!(llms_json.source.url, loaded_json.source.url);
808        assert_eq!(
809            llms_json.line_index.total_lines,
810            loaded_json.line_index.total_lines
811        );
812    }
813
814    #[test]
815    fn test_source_exists() {
816        let (storage, _temp_dir) = create_test_storage();
817
818        // Initially should not exist
819        assert!(!storage.exists("react"));
820
821        // After saving llms.json, should exist
822        let llms_json = create_test_llms_json("react");
823        storage
824            .save_llms_json("react", &llms_json)
825            .expect("Should save");
826
827        assert!(storage.exists("react"));
828    }
829
830    #[test]
831    fn test_list_sources_empty() {
832        let (storage, _temp_dir) = create_test_storage();
833
834        let sources = storage.list_sources();
835        assert!(sources.is_empty());
836    }
837
838    #[test]
839    fn test_list_sources_with_data() {
840        let (storage, _temp_dir) = create_test_storage();
841
842        // Add multiple sources
843        let aliases = ["react", "nextjs", "rust"];
844        for &alias in &aliases {
845            let llms_json = create_test_llms_json(alias);
846            storage
847                .save_llms_json(alias, &llms_json)
848                .expect("Should save");
849        }
850
851        let sources = storage.list_sources();
852        assert_eq!(sources.len(), 3);
853
854        // Should be sorted
855        assert_eq!(sources, vec!["nextjs", "react", "rust"]);
856    }
857
858    #[test]
859    fn test_list_sources_ignores_hidden_dirs() {
860        let (storage, temp_dir) = create_test_storage();
861
862        // Create a hidden directory
863        let hidden_dir = temp_dir.path().join(".hidden");
864        fs::create_dir(&hidden_dir).expect("Should create hidden dir");
865
866        // Create a regular source
867        let llms_json = create_test_llms_json("react");
868        storage
869            .save_llms_json("react", &llms_json)
870            .expect("Should save");
871
872        let sources = storage.list_sources();
873        assert_eq!(sources.len(), 1);
874        assert_eq!(sources[0], "react");
875    }
876
877    #[test]
878    fn test_list_sources_requires_llms_json() {
879        let (storage, _temp_dir) = create_test_storage();
880
881        // Create tool directory without llms.json
882        storage
883            .ensure_tool_dir("incomplete")
884            .expect("Should create dir");
885
886        // Save only llms.txt (no llms.json)
887        storage
888            .save_llms_txt("incomplete", "# Test content")
889            .expect("Should save txt");
890
891        // Create another source with complete data
892        let llms_json = create_test_llms_json("complete");
893        storage
894            .save_llms_json("complete", &llms_json)
895            .expect("Should save json");
896
897        let sources = storage.list_sources();
898        assert_eq!(sources.len(), 1);
899        assert_eq!(sources[0], "complete");
900    }
901
902    #[test]
903    fn test_available_flavors_empty_when_alias_missing() {
904        let (storage, _temp_dir) = create_test_storage();
905        let flavors = storage
906            .available_flavors("unknown")
907            .expect("should handle missing alias");
908        assert!(flavors.is_empty());
909    }
910
911    #[test]
912    fn test_available_flavors_lists_variants() {
913        let (storage, _temp_dir) = create_test_storage();
914
915        let llms_json = create_test_llms_json("react");
916        storage
917            .save_flavor_json("react", "llms", &llms_json)
918            .expect("should save llms json");
919        storage
920            .save_flavor_json("react", "llms-full", &llms_json)
921            .expect("should save llms-full json");
922
923        // Metadata files should be ignored
924        let metadata_path = storage
925            .metadata_path_for_flavor("react", "llms-full")
926            .expect("metadata path");
927        fs::write(&metadata_path, "{}").expect("write metadata");
928
929        let flavors = storage
930            .available_flavors("react")
931            .expect("should list flavors");
932        assert_eq!(flavors, vec!["llms".to_string(), "llms-full".to_string()]);
933    }
934
935    #[test]
936    fn test_archive_functionality() {
937        let (storage, _temp_dir) = create_test_storage();
938
939        // Create source data
940        let content = "# Test content";
941        let llms_json = create_test_llms_json("test");
942
943        storage
944            .save_llms_txt("test", content)
945            .expect("Should save txt");
946        storage
947            .save_llms_json("test", &llms_json)
948            .expect("Should save json");
949
950        // Archive the source
951        storage.archive("test").expect("Should archive");
952
953        // Verify archive directory exists
954        let archive_dir = storage.archive_dir("test").expect("Should get archive dir");
955        assert!(archive_dir.exists());
956
957        // Verify archived files exist (names contain timestamp)
958        let archive_entries: Vec<_> = fs::read_dir(&archive_dir)
959            .expect("Should read archive dir")
960            .collect::<std::result::Result<Vec<_>, std::io::Error>>()
961            .expect("Should collect entries");
962
963        assert_eq!(archive_entries.len(), 2); // llms.txt and llms.json
964
965        // Verify archived files have correct names
966        let mut has_txt = false;
967        let mut has_json = false;
968        for entry in archive_entries {
969            let name = entry.file_name().to_string_lossy().to_string();
970            if name.contains("llms.txt") {
971                has_txt = true;
972            }
973            if name.contains("llms.json") {
974                has_json = true;
975            }
976        }
977
978        assert!(has_txt, "Should have archived llms.txt");
979        assert!(has_json, "Should have archived llms.json");
980    }
981
982    #[test]
983    fn test_archive_missing_files() {
984        let (storage, _temp_dir) = create_test_storage();
985
986        // Archive non-existent source - should not fail
987        let result = storage.archive("nonexistent");
988        assert!(result.is_ok());
989
990        // Archive directory should still be created
991        let archive_dir = storage
992            .archive_dir("nonexistent")
993            .expect("Should get archive dir");
994        assert!(archive_dir.exists());
995    }
996
997    #[test]
998    fn test_load_missing_files_returns_error() {
999        let (storage, _temp_dir) = create_test_storage();
1000
1001        let result = storage.load_llms_txt("nonexistent");
1002        assert!(result.is_err());
1003
1004        let result = storage.load_llms_json("nonexistent");
1005        assert!(result.is_err());
1006    }
1007
1008    #[test]
1009    fn test_json_serialization_roundtrip() {
1010        let (storage, _temp_dir) = create_test_storage();
1011
1012        let original = create_test_llms_json("test");
1013
1014        // Save and load
1015        storage
1016            .save_llms_json("test", &original)
1017            .expect("Should save");
1018        let loaded = storage.load_llms_json("test").expect("Should load");
1019
1020        // Verify all fields are preserved
1021        assert_eq!(original.alias, loaded.alias);
1022        assert_eq!(original.source.url, loaded.source.url);
1023        assert_eq!(original.source.sha256, loaded.source.sha256);
1024        assert_eq!(original.toc.len(), loaded.toc.len());
1025        assert_eq!(original.files.len(), loaded.files.len());
1026        assert_eq!(
1027            original.line_index.total_lines,
1028            loaded.line_index.total_lines
1029        );
1030        assert_eq!(original.diagnostics.len(), loaded.diagnostics.len());
1031    }
1032
1033    #[test]
1034    fn test_flavor_file_path() {
1035        let (storage, _temp_dir) = create_test_storage();
1036
1037        // Test standard flavors
1038        let llms_path = storage
1039            .flavor_file_path("test-alias", "llms")
1040            .expect("Should get llms path");
1041        assert!(llms_path.ends_with("test-alias/llms.txt"));
1042
1043        let llms_full_path = storage
1044            .flavor_file_path("test-alias", "llms-full")
1045            .expect("Should get llms-full path");
1046        assert!(llms_full_path.ends_with("test-alias/llms-full.txt"));
1047
1048        // Test custom flavor
1049        let custom_path = storage
1050            .flavor_file_path("test-alias", "custom-flavor")
1051            .expect("Should get custom flavor path");
1052        assert!(custom_path.ends_with("test-alias/custom-flavor.txt"));
1053    }
1054}