1use crate::{Error, LlmsJson, Result, Source, SourceDescriptor, profile};
2use chrono::Utc;
3use directories::{BaseDirs, ProjectDirs};
4use std::fs;
5use std::path::{Path, PathBuf};
6use tracing::{debug, info, warn};
7
8const MAX_ALIAS_LEN: usize = 64;
10
11pub struct Storage {
13 root_dir: PathBuf,
14 config_dir: PathBuf,
15}
16
17impl Storage {
18 fn sanitize_variant_file_name(name: &str) -> String {
19 let mut sanitized: String = name
25 .chars()
26 .map(|c| {
27 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
28 c
29 } else {
30 '_'
31 }
32 })
33 .collect();
34
35 while sanitized.contains("..") {
39 sanitized = sanitized.replace("..", "_");
40 }
41
42 if sanitized.is_empty() {
43 "llms.txt".to_string()
44 } else {
45 sanitized
46 }
47 }
48
49 pub fn new() -> Result<Self> {
60 if let Ok(dir) = std::env::var("BLZ_DATA_DIR") {
62 let root = PathBuf::from(dir);
63 let config_dir = Self::default_config_dir()?;
64 return Self::with_paths(root, config_dir);
65 }
66
67 let root_dir = if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
69 let trimmed = xdg.trim();
70 if trimmed.is_empty() {
71 Self::fallback_data_dir()?
72 } else {
73 PathBuf::from(trimmed).join(profile::app_dir_slug())
74 }
75 } else {
76 Self::fallback_data_dir()?
77 };
78
79 Self::check_and_migrate_old_cache(&root_dir);
81
82 let config_dir = Self::default_config_dir()?;
83 Self::with_paths(root_dir, config_dir)
84 }
85
86 fn fallback_data_dir() -> Result<PathBuf> {
88 let home = directories::BaseDirs::new()
90 .ok_or_else(|| Error::Storage("Failed to determine home directory".into()))?;
91 Ok(home.home_dir().join(profile::dot_dir_slug()))
92 }
93
94 fn default_config_dir() -> Result<PathBuf> {
96 if let Ok(dir) = std::env::var("BLZ_CONFIG_DIR") {
97 let trimmed = dir.trim();
98 if !trimmed.is_empty() {
99 return Ok(PathBuf::from(trimmed));
100 }
101 }
102
103 if let Ok(dir) = std::env::var("BLZ_GLOBAL_CONFIG_DIR") {
104 let trimmed = dir.trim();
105 if !trimmed.is_empty() {
106 return Ok(PathBuf::from(trimmed));
107 }
108 }
109
110 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
111 let trimmed = xdg.trim();
112 if !trimmed.is_empty() {
113 return Ok(PathBuf::from(trimmed).join(profile::app_dir_slug()));
114 }
115 }
116
117 if let Some(base_dirs) = BaseDirs::new() {
118 return Ok(base_dirs.home_dir().join(profile::dot_dir_slug()));
119 }
120
121 Err(Error::Storage(
122 "Failed to determine configuration directory".into(),
123 ))
124 }
125
126 pub fn with_root(root_dir: PathBuf) -> Result<Self> {
132 let config_dir = root_dir.join("config");
133 Self::with_paths(root_dir, config_dir)
134 }
135
136 pub fn with_paths(root_dir: PathBuf, config_dir: PathBuf) -> Result<Self> {
142 fs::create_dir_all(&root_dir)
143 .map_err(|e| Error::Storage(format!("Failed to create root directory: {e}")))?;
144 fs::create_dir_all(&config_dir)
145 .map_err(|e| Error::Storage(format!("Failed to create config directory: {e}")))?;
146
147 Ok(Self {
148 root_dir,
149 config_dir,
150 })
151 }
152
153 #[must_use]
155 pub fn root_dir(&self) -> &Path {
156 &self.root_dir
157 }
158
159 #[must_use]
161 pub fn config_dir(&self) -> &Path {
162 &self.config_dir
163 }
164
165 fn descriptors_dir(&self) -> PathBuf {
166 self.config_dir.join("sources")
167 }
168
169 pub fn descriptor_path(&self, alias: &str) -> Result<PathBuf> {
175 Self::validate_alias(alias)?;
176 Ok(self.descriptors_dir().join(format!("{alias}.toml")))
177 }
178
179 pub fn save_descriptor(&self, descriptor: &SourceDescriptor) -> Result<()> {
185 let path = self.descriptor_path(&descriptor.alias)?;
186 if let Some(parent) = path.parent() {
187 fs::create_dir_all(parent)
188 .map_err(|e| Error::Storage(format!("Failed to create descriptor dir: {e}")))?;
189 }
190
191 let toml = toml::to_string_pretty(descriptor)
192 .map_err(|e| Error::Storage(format!("Failed to serialize descriptor: {e}")))?;
193 fs::write(&path, toml)
194 .map_err(|e| Error::Storage(format!("Failed to write descriptor: {e}")))?;
195 Ok(())
196 }
197
198 pub fn load_descriptor(&self, alias: &str) -> Result<Option<SourceDescriptor>> {
204 let path = self.descriptor_path(alias)?;
205 if !path.exists() {
206 return Ok(None);
207 }
208
209 let contents = fs::read_to_string(&path)
210 .map_err(|e| Error::Storage(format!("Failed to read descriptor: {e}")))?;
211 let descriptor = toml::from_str::<SourceDescriptor>(&contents)
212 .map_err(|e| Error::Storage(format!("Failed to parse descriptor: {e}")))?;
213 Ok(Some(descriptor))
214 }
215
216 pub fn remove_descriptor(&self, alias: &str) -> Result<()> {
222 let path = self.descriptor_path(alias)?;
223 if path.exists() {
224 fs::remove_file(&path)
225 .map_err(|e| Error::Storage(format!("Failed to remove descriptor: {e}")))?;
226 }
227 Ok(())
228 }
229
230 pub fn tool_dir(&self, source: &str) -> Result<PathBuf> {
236 Self::validate_alias(source)?;
238 Ok(self.root_dir.join("sources").join(source))
239 }
240
241 fn variant_file_path(&self, source: &str, file_name: &str) -> Result<PathBuf> {
243 let sanitized = Self::sanitize_variant_file_name(file_name);
244 Ok(self.tool_dir(source)?.join(sanitized))
245 }
246
247 pub fn ensure_tool_dir(&self, source: &str) -> Result<PathBuf> {
253 let dir = self.tool_dir(source)?;
254 fs::create_dir_all(&dir)
255 .map_err(|e| Error::Storage(format!("Failed to create tool directory: {e}")))?;
256 Ok(dir)
257 }
258
259 fn validate_alias(alias: &str) -> Result<()> {
264 if alias.is_empty() {
266 return Err(Error::Storage("Alias cannot be empty".into()));
267 }
268
269 if alias.starts_with('-') {
271 return Err(Error::Storage(format!(
272 "Invalid alias '{alias}': cannot start with '-'"
273 )));
274 }
275
276 if alias.contains("..") || alias.contains('/') || alias.contains('\\') {
278 return Err(Error::Storage(format!(
279 "Invalid alias '{alias}': contains path traversal characters"
280 )));
281 }
282
283 if alias.starts_with('.') || alias.contains('\0') {
285 return Err(Error::Storage(format!(
286 "Invalid alias '{alias}': contains invalid filesystem characters"
287 )));
288 }
289
290 #[cfg(target_os = "windows")]
292 {
293 const RESERVED_NAMES: &[&str] = &[
294 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
295 "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
296 "LPT9",
297 ];
298
299 let upper_alias = alias.to_uppercase();
300 if RESERVED_NAMES.contains(&upper_alias.as_str()) {
301 return Err(Error::Storage(format!(
302 "Invalid alias '{}': reserved name on Windows",
303 alias
304 )));
305 }
306 }
307
308 if alias.len() > MAX_ALIAS_LEN {
310 return Err(Error::Storage(format!(
311 "Invalid alias '{alias}': exceeds maximum length of {MAX_ALIAS_LEN} characters"
312 )));
313 }
314
315 if !alias
317 .chars()
318 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
319 {
320 return Err(Error::Storage(format!(
321 "Invalid alias '{alias}': only [A-Za-z0-9_-] are allowed"
322 )));
323 }
324
325 Ok(())
326 }
327
328 pub fn llms_txt_path(&self, source: &str) -> Result<PathBuf> {
334 self.variant_file_path(source, "llms.txt")
335 }
336
337 pub fn llms_json_path(&self, source: &str) -> Result<PathBuf> {
343 Ok(self.tool_dir(source)?.join("llms.json"))
344 }
345
346 pub fn index_dir(&self, source: &str) -> Result<PathBuf> {
352 Ok(self.tool_dir(source)?.join(".index"))
353 }
354
355 pub fn archive_dir(&self, source: &str) -> Result<PathBuf> {
361 Ok(self.tool_dir(source)?.join(".archive"))
362 }
363
364 pub fn metadata_path(&self, source: &str) -> Result<PathBuf> {
370 Ok(self.tool_dir(source)?.join("metadata.json"))
371 }
372
373 pub fn anchors_map_path(&self, source: &str) -> Result<PathBuf> {
379 Ok(self.tool_dir(source)?.join("anchors.json"))
380 }
381
382 pub fn save_llms_txt(&self, source: &str, content: &str) -> Result<()> {
388 self.ensure_tool_dir(source)?;
389 let path = self.llms_txt_path(source)?;
390
391 let tmp_path = path.with_extension("tmp");
392 fs::write(&tmp_path, content)
393 .map_err(|e| Error::Storage(format!("Failed to write llms.txt: {e}")))?;
394
395 #[cfg(target_os = "windows")]
396 if path.exists() {
397 fs::remove_file(&path)
398 .map_err(|e| Error::Storage(format!("Failed to remove existing llms.txt: {e}")))?;
399 }
400
401 fs::rename(&tmp_path, &path)
402 .map_err(|e| Error::Storage(format!("Failed to commit llms.txt: {e}")))?;
403
404 debug!("Saved llms.txt for {}", source);
405 Ok(())
406 }
407
408 pub fn load_llms_txt(&self, source: &str) -> Result<String> {
414 let path = self.llms_txt_path(source)?;
415 fs::read_to_string(&path)
416 .map_err(|e| Error::Storage(format!("Failed to read llms.txt: {e}")))
417 }
418
419 pub fn save_llms_json(&self, source: &str, data: &LlmsJson) -> Result<()> {
425 self.ensure_tool_dir(source)?;
426 let path = self.llms_json_path(source)?;
427 let json = serde_json::to_string_pretty(data)
428 .map_err(|e| Error::Storage(format!("Failed to serialize JSON: {e}")))?;
429
430 let tmp_path = path.with_extension("json.tmp");
431 fs::write(&tmp_path, json)
432 .map_err(|e| Error::Storage(format!("Failed to write llms.json: {e}")))?;
433
434 #[cfg(target_os = "windows")]
435 if path.exists() {
436 fs::remove_file(&path)
437 .map_err(|e| Error::Storage(format!("Failed to remove existing llms.json: {e}")))?;
438 }
439 fs::rename(&tmp_path, &path)
440 .map_err(|e| Error::Storage(format!("Failed to commit llms.json: {e}")))?;
441
442 debug!("Saved llms.json for {}", source);
443 Ok(())
444 }
445
446 pub fn load_llms_json(&self, source: &str) -> Result<LlmsJson> {
452 let path = self.llms_json_path(source)?;
453 if !path.exists() {
454 return Err(Error::Storage(format!(
455 "llms.json missing for source '{source}'"
456 )));
457 }
458 let json = fs::read_to_string(&path)
459 .map_err(|e| Error::Storage(format!("Failed to read llms.json: {e}")))?;
460
461 if let Ok(raw_value) = serde_json::from_str::<serde_json::Value>(&json) {
463 if let Some(obj) = raw_value.as_object() {
464 if obj.contains_key("alias")
466 || (obj.contains_key("source") && obj["source"].is_object())
467 {
468 return Err(Error::Storage(format!(
469 "Incompatible cache format detected for source '{source}'.\n\n\
470 This cache was created with blz v0.4.x or earlier and is not compatible with v0.5.0+.\n\n\
471 To fix this, clear your cache:\n \
472 blz clear --force\n\n\
473 Then re-add your sources."
474 )));
475 }
476 }
477 }
478
479 let data = serde_json::from_str(&json)
480 .map_err(|e| Error::Storage(format!("Failed to parse llms.json: {e}")))?;
481 Ok(data)
482 }
483
484 pub fn save_source_metadata(&self, source: &str, metadata: &Source) -> Result<()> {
490 self.ensure_tool_dir(source)?;
491 let path = self.metadata_path(source)?;
492 let json = serde_json::to_string_pretty(metadata)
493 .map_err(|e| Error::Storage(format!("Failed to serialize metadata: {e}")))?;
494
495 let tmp_path = path.with_extension("json.tmp");
497 fs::write(&tmp_path, &json)
498 .map_err(|e| Error::Storage(format!("Failed to write temp metadata: {e}")))?;
499
500 #[cfg(target_os = "windows")]
502 if path.exists() {
503 fs::remove_file(&path)
504 .map_err(|e| Error::Storage(format!("Failed to remove existing metadata: {e}")))?;
505 }
506 fs::rename(&tmp_path, &path)
507 .map_err(|e| Error::Storage(format!("Failed to persist metadata: {e}")))?;
508
509 debug!("Saved metadata for {}", source);
510 Ok(())
511 }
512
513 pub fn save_anchors_map(&self, source: &str, map: &crate::AnchorsMap) -> Result<()> {
519 self.ensure_tool_dir(source)?;
520 let path = self.anchors_map_path(source)?;
521 let json = serde_json::to_string_pretty(map)
522 .map_err(|e| Error::Storage(format!("Failed to serialize anchors map: {e}")))?;
523 fs::write(&path, json)
524 .map_err(|e| Error::Storage(format!("Failed to write anchors map: {e}")))?;
525 Ok(())
526 }
527
528 pub fn load_source_metadata(&self, source: &str) -> Result<Option<Source>> {
534 let path = self.metadata_path(source)?;
535 if !path.exists() {
536 return Ok(None);
537 }
538 let json = fs::read_to_string(&path)
539 .map_err(|e| Error::Storage(format!("Failed to read metadata: {e}")))?;
540 let metadata = serde_json::from_str(&json)
541 .map_err(|e| Error::Storage(format!("Failed to parse metadata: {e}")))?;
542 Ok(Some(metadata))
543 }
544
545 #[must_use]
547 pub fn exists(&self, source: &str) -> bool {
548 self.llms_json_path(source)
549 .map(|path| path.exists())
550 .unwrap_or(false)
551 }
552
553 #[must_use]
555 pub fn list_sources(&self) -> Vec<String> {
556 let mut sources = Vec::new();
557 let sources_dir = self.root_dir.join("sources");
558
559 if let Ok(entries) = fs::read_dir(&sources_dir) {
560 for entry in entries.flatten() {
561 if entry.path().is_dir() {
562 if let Some(name) = entry.file_name().to_str() {
563 if !name.starts_with('.') && self.exists(name) {
564 sources.push(name.to_string());
565 }
566 }
567 }
568 }
569 }
570
571 sources.sort();
572 sources
573 }
574
575 pub fn clear_cache(&self) -> Result<()> {
583 if self.root_dir.exists() {
585 fs::remove_dir_all(&self.root_dir)
586 .map_err(|e| Error::Storage(format!("Failed to remove cache directory: {e}")))?;
587 }
588
589 fs::create_dir_all(&self.root_dir)
591 .map_err(|e| Error::Storage(format!("Failed to recreate cache directory: {e}")))?;
592
593 Ok(())
594 }
595
596 pub fn archive(&self, source: &str) -> Result<()> {
602 let archive_dir = self.archive_dir(source)?;
603 fs::create_dir_all(&archive_dir)
604 .map_err(|e| Error::Storage(format!("Failed to create archive directory: {e}")))?;
605
606 let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%SZ");
608
609 let dir = self.tool_dir(source)?;
611 if dir.exists() {
612 for entry in fs::read_dir(&dir)
613 .map_err(|e| Error::Storage(format!("Failed to read dir for archive: {e}")))?
614 {
615 let entry =
616 entry.map_err(|e| Error::Storage(format!("Failed to read entry: {e}")))?;
617 let path = entry.path();
618 if !path.is_file() {
619 continue;
620 }
621 let name = entry.file_name();
622 let name_str = name.to_string_lossy().to_lowercase();
623 let is_json = std::path::Path::new(&name_str)
625 .extension()
626 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"));
627 let is_txt = std::path::Path::new(&name_str)
628 .extension()
629 .is_some_and(|ext| ext.eq_ignore_ascii_case("txt"));
630 let is_llms_artifact = (is_json || is_txt) && name_str.starts_with("llms");
631 if is_llms_artifact {
632 let archive_path =
633 archive_dir.join(format!("{timestamp}-{}", name.to_string_lossy()));
634 fs::copy(&path, &archive_path).map_err(|e| {
635 Error::Storage(format!("Failed to archive {}: {e}", path.display()))
636 })?;
637 }
638 }
639 }
640
641 info!("Archived {} at {}", source, timestamp);
642 Ok(())
643 }
644
645 fn check_and_migrate_old_cache(new_root: &Path) {
647 let old_project_dirs = ProjectDirs::from("dev", "outfitter", "cache");
649
650 if let Some(old_dirs) = old_project_dirs {
651 let old_root = old_dirs.data_dir();
652
653 if old_root.exists() && old_root.is_dir() {
655 let has_content = fs::read_dir(old_root)
657 .map(|entries| {
658 entries.filter_map(std::result::Result::ok).any(|entry| {
659 let path = entry.path();
660 if !path.is_dir() {
661 return false;
662 }
663 let has_llms_json = path.join("llms.json").exists();
664 let has_llms_txt = path.join("llms.txt").exists();
665 let has_metadata = path.join("metadata.json").exists();
666 has_llms_json || has_llms_txt || has_metadata
667 })
668 })
669 .unwrap_or(false);
670 if has_content {
671 if new_root.exists()
673 && fs::read_dir(new_root)
674 .map(|mut e| e.next().is_some())
675 .unwrap_or(false)
676 {
677 warn!(
679 "Found old cache at {} but new cache at {} already exists. \
680 Manual migration may be needed if you want to preserve old data.",
681 old_root.display(),
682 new_root.display()
683 );
684 } else {
685 info!(
687 "Migrating cache from old location {} to new location {}",
688 old_root.display(),
689 new_root.display()
690 );
691
692 if let Err(e) = Self::migrate_directory(old_root, new_root) {
693 warn!(
695 "Could not automatically migrate cache: {}. \
696 Starting with fresh cache at {}. \
697 To manually migrate, copy contents from {} to {}",
698 e,
699 new_root.display(),
700 old_root.display(),
701 new_root.display()
702 );
703 } else {
704 info!("Successfully migrated cache to new location");
705 }
706 }
707 }
708 }
709 }
710 }
711
712 fn migrate_directory(from: &Path, to: &Path) -> Result<()> {
714 fs::create_dir_all(to)
716 .map_err(|e| Error::Storage(format!("Failed to create migration target: {e}")))?;
717
718 for entry in fs::read_dir(from)
720 .map_err(|e| Error::Storage(format!("Failed to read migration source: {e}")))?
721 {
722 let entry = entry
723 .map_err(|e| Error::Storage(format!("Failed to read directory entry: {e}")))?;
724 let path = entry.path();
725 let file_name = entry.file_name();
726 let target_path = to.join(&file_name);
727
728 if path.is_dir() {
729 Self::migrate_directory(&path, &target_path)?;
731 } else {
732 fs::copy(&path, &target_path).map_err(|e| {
734 Error::Storage(format!("Failed to copy file during migration: {e}"))
735 })?;
736 }
737 }
738
739 Ok(())
740 }
741}
742
743#[cfg(test)]
747#[allow(clippy::unwrap_used)]
748mod tests {
749 use super::*;
750 use crate::types::{FileInfo, LineIndex, Source, SourceVariant, TocEntry};
751 use std::fs;
752 use tempfile::TempDir;
753
754 fn create_test_storage() -> (Storage, TempDir) {
755 let temp_dir = TempDir::new().expect("Failed to create temp directory");
756 let storage = Storage::with_root(temp_dir.path().to_path_buf())
757 .expect("Failed to create test storage");
758 (storage, temp_dir)
759 }
760
761 fn create_test_llms_json(source_name: &str) -> LlmsJson {
762 LlmsJson {
763 source: source_name.to_string(),
764 metadata: Source {
765 url: format!("https://example.com/{source_name}/llms.txt"),
766 etag: Some("abc123".to_string()),
767 last_modified: None,
768 fetched_at: Utc::now(),
769 sha256: "deadbeef".to_string(),
770 variant: SourceVariant::Llms,
771 aliases: Vec::new(),
772 tags: Vec::new(),
773 description: None,
774 category: None,
775 npm_aliases: Vec::new(),
776 github_aliases: Vec::new(),
777 origin: crate::types::SourceOrigin {
778 manifest: None,
779 source_type: Some(crate::types::SourceType::Remote {
780 url: format!("https://example.com/{source_name}/llms.txt"),
781 }),
782 },
783 filter_non_english: None,
784 },
785 toc: vec![TocEntry {
786 heading_path: vec!["Getting Started".to_string()],
787 heading_path_display: Some(vec!["Getting Started".to_string()]),
788 heading_path_normalized: Some(vec!["getting started".to_string()]),
789 lines: "1-50".to_string(),
790 anchor: None,
791 children: vec![],
792 }],
793 files: vec![FileInfo {
794 path: "llms.txt".to_string(),
795 sha256: "deadbeef".to_string(),
796 }],
797 line_index: LineIndex {
798 total_lines: 100,
799 byte_offsets: false,
800 },
801 diagnostics: vec![],
802 parse_meta: None,
803 filter_stats: None,
804 }
805 }
806
807 #[test]
808 fn test_storage_creation_with_root() {
809 let temp_dir = TempDir::new().expect("Failed to create temp directory");
810 let storage = Storage::with_root(temp_dir.path().to_path_buf());
811
812 assert!(storage.is_ok());
813 let _storage = storage.unwrap();
814
815 assert!(temp_dir.path().exists());
817 }
818
819 #[test]
820 fn test_tool_directory_paths() {
821 let (storage, _temp_dir) = create_test_storage();
822
823 let tool_dir = storage.tool_dir("react").expect("Should get tool dir");
824 let llms_txt_path = storage
825 .llms_txt_path("react")
826 .expect("Should get llms.txt path");
827 let llms_json_path = storage
828 .llms_json_path("react")
829 .expect("Should get llms.json path");
830 let index_dir = storage.index_dir("react").expect("Should get index dir");
831 let archive_dir = storage
832 .archive_dir("react")
833 .expect("Should get archive dir");
834
835 assert!(tool_dir.ends_with("react"));
836 assert!(llms_txt_path.ends_with("react/llms.txt"));
837 assert!(llms_json_path.ends_with("react/llms.json"));
838 assert!(index_dir.ends_with("react/.index"));
839 assert!(archive_dir.ends_with("react/.archive"));
840 }
841
842 #[test]
843 fn test_invalid_alias_validation() {
844 let (storage, _temp_dir) = create_test_storage();
845
846 assert!(storage.tool_dir("../etc").is_err());
848 assert!(storage.tool_dir("../../passwd").is_err());
849 assert!(storage.tool_dir("test/../../../etc").is_err());
850
851 assert!(storage.tool_dir(".hidden").is_err());
853 assert!(storage.tool_dir("test\0null").is_err());
854 assert!(storage.tool_dir("test/slash").is_err());
855 assert!(storage.tool_dir("test\\backslash").is_err());
856
857 assert!(storage.tool_dir("").is_err());
859
860 assert!(storage.tool_dir("react").is_ok());
862 assert!(storage.tool_dir("my-tool").is_ok());
863 assert!(storage.tool_dir("tool_123").is_ok());
864 }
865
866 #[test]
867 fn test_ensure_tool_directory() {
868 let (storage, _temp_dir) = create_test_storage();
869
870 let tool_dir = storage
871 .ensure_tool_dir("react")
872 .expect("Should create tool dir");
873 assert!(tool_dir.exists());
874
875 let tool_dir2 = storage
877 .ensure_tool_dir("react")
878 .expect("Should not fail on existing dir");
879 assert_eq!(tool_dir, tool_dir2);
880 }
881
882 #[test]
883 fn test_save_and_load_llms_txt() {
884 let (storage, _temp_dir) = create_test_storage();
885
886 let content = "# React Documentation\n\nThis is the React documentation...";
887
888 storage
890 .save_llms_txt("react", content)
891 .expect("Should save llms.txt");
892
893 assert!(
895 storage
896 .llms_txt_path("react")
897 .expect("Should get path")
898 .exists()
899 );
900
901 let loaded_content = storage
903 .load_llms_txt("react")
904 .expect("Should load llms.txt");
905 assert_eq!(content, loaded_content);
906 }
907
908 #[test]
909 fn test_save_and_load_llms_json() {
910 let (storage, _temp_dir) = create_test_storage();
911
912 let llms_json = create_test_llms_json("react");
913
914 storage
916 .save_llms_json("react", &llms_json)
917 .expect("Should save llms.json");
918
919 assert!(
921 storage
922 .llms_json_path("react")
923 .expect("Should get path")
924 .exists()
925 );
926
927 let loaded_json = storage
929 .load_llms_json("react")
930 .expect("Should load llms.json");
931 assert_eq!(llms_json.source, loaded_json.source);
932 assert_eq!(llms_json.metadata.url, loaded_json.metadata.url);
933 assert_eq!(
934 llms_json.line_index.total_lines,
935 loaded_json.line_index.total_lines
936 );
937 }
938
939 #[test]
940 fn test_source_exists() {
941 let (storage, _temp_dir) = create_test_storage();
942
943 assert!(!storage.exists("react"));
945
946 let llms_json = create_test_llms_json("react");
948 storage
949 .save_llms_json("react", &llms_json)
950 .expect("Should save");
951
952 assert!(storage.exists("react"));
953 }
954
955 #[test]
956 fn test_list_sources_empty() {
957 let (storage, _temp_dir) = create_test_storage();
958
959 let sources = storage.list_sources();
960 assert!(sources.is_empty());
961 }
962
963 #[test]
964 fn test_list_sources_with_data() {
965 let (storage, _temp_dir) = create_test_storage();
966
967 let aliases = ["react", "nextjs", "rust"];
969 for &alias in &aliases {
970 let llms_json = create_test_llms_json(alias);
971 storage
972 .save_llms_json(alias, &llms_json)
973 .expect("Should save");
974 }
975
976 let sources = storage.list_sources();
977 assert_eq!(sources.len(), 3);
978
979 assert_eq!(sources, vec!["nextjs", "react", "rust"]);
981 }
982
983 #[test]
984 fn test_list_sources_ignores_hidden_dirs() {
985 let (storage, temp_dir) = create_test_storage();
986
987 let hidden_dir = temp_dir.path().join(".hidden");
989 fs::create_dir(&hidden_dir).expect("Should create hidden dir");
990
991 let llms_json = create_test_llms_json("react");
993 storage
994 .save_llms_json("react", &llms_json)
995 .expect("Should save");
996
997 let sources = storage.list_sources();
998 assert_eq!(sources.len(), 1);
999 assert_eq!(sources[0], "react");
1000 }
1001
1002 #[test]
1003 fn test_list_sources_requires_llms_json() {
1004 let (storage, _temp_dir) = create_test_storage();
1005
1006 storage
1008 .ensure_tool_dir("incomplete")
1009 .expect("Should create dir");
1010
1011 storage
1013 .save_llms_txt("incomplete", "# Test content")
1014 .expect("Should save txt");
1015
1016 let llms_json = create_test_llms_json("complete");
1018 storage
1019 .save_llms_json("complete", &llms_json)
1020 .expect("Should save json");
1021
1022 let sources = storage.list_sources();
1023 assert_eq!(sources.len(), 1);
1024 assert_eq!(sources[0], "complete");
1025 }
1026
1027 #[test]
1028 fn test_archive_functionality() {
1029 let (storage, _temp_dir) = create_test_storage();
1030
1031 let content = "# Test content";
1033 let llms_json = create_test_llms_json("test");
1034
1035 storage
1036 .save_llms_txt("test", content)
1037 .expect("Should save txt");
1038 storage
1039 .save_llms_json("test", &llms_json)
1040 .expect("Should save json");
1041
1042 storage.archive("test").expect("Should archive");
1044
1045 let archive_dir = storage.archive_dir("test").expect("Should get archive dir");
1047 assert!(archive_dir.exists());
1048
1049 let archive_entries: Vec<_> = fs::read_dir(&archive_dir)
1051 .expect("Should read archive dir")
1052 .collect::<std::result::Result<Vec<_>, std::io::Error>>()
1053 .expect("Should collect entries");
1054
1055 assert_eq!(archive_entries.len(), 2); let mut has_txt = false;
1059 let mut has_json = false;
1060 for entry in archive_entries {
1061 let name = entry.file_name().to_string_lossy().to_string();
1062 if name.contains("llms.txt") {
1063 has_txt = true;
1064 }
1065 if name.contains("llms.json") {
1066 has_json = true;
1067 }
1068 }
1069
1070 assert!(has_txt, "Should have archived llms.txt");
1071 assert!(has_json, "Should have archived llms.json");
1072 }
1073
1074 #[test]
1075 fn test_archive_missing_files() {
1076 let (storage, _temp_dir) = create_test_storage();
1077
1078 let result = storage.archive("nonexistent");
1080 assert!(result.is_ok());
1081
1082 let archive_dir = storage
1084 .archive_dir("nonexistent")
1085 .expect("Should get archive dir");
1086 assert!(archive_dir.exists());
1087 }
1088
1089 #[test]
1090 fn test_load_missing_files_returns_error() {
1091 let (storage, _temp_dir) = create_test_storage();
1092
1093 let result = storage.load_llms_txt("nonexistent");
1094 assert!(result.is_err());
1095
1096 let result = storage.load_llms_json("nonexistent");
1097 assert!(result.is_err());
1098 }
1099
1100 #[test]
1101 fn test_json_serialization_roundtrip() {
1102 let (storage, _temp_dir) = create_test_storage();
1103
1104 let original = create_test_llms_json("test");
1105
1106 storage
1108 .save_llms_json("test", &original)
1109 .expect("Should save");
1110 let loaded = storage.load_llms_json("test").expect("Should load");
1111
1112 assert_eq!(original.source, loaded.source);
1114 assert_eq!(original.metadata.url, loaded.metadata.url);
1115 assert_eq!(original.metadata.sha256, loaded.metadata.sha256);
1116 assert_eq!(original.toc.len(), loaded.toc.len());
1117 assert_eq!(original.files.len(), loaded.files.len());
1118 assert_eq!(
1119 original.line_index.total_lines,
1120 loaded.line_index.total_lines
1121 );
1122 assert_eq!(original.diagnostics.len(), loaded.diagnostics.len());
1123 }
1124}