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
8const MAX_ALIAS_LEN: usize = 64;
10
11pub struct Storage {
13 root_dir: PathBuf,
14}
15
16impl Storage {
17 fn sanitize_variant_file_name(name: &str) -> String {
18 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 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 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 pub fn new() -> Result<Self> {
82 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 Self::check_and_migrate_old_cache(&root_dir);
95
96 Self::with_root(root_dir)
97 }
98
99 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 pub fn tool_dir(&self, alias: &str) -> Result<PathBuf> {
109 Self::validate_alias(alias)?;
111 Ok(self.root_dir.join(alias))
112 }
113
114 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 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 fn validate_alias(alias: &str) -> Result<()> {
133 if alias.is_empty() {
135 return Err(Error::Storage("Alias cannot be empty".into()));
136 }
137
138 if alias.starts_with('-') {
140 return Err(Error::Storage(format!(
141 "Invalid alias '{alias}': cannot start with '-'"
142 )));
143 }
144
145 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 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 #[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 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 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 pub fn llms_txt_path(&self, alias: &str) -> Result<PathBuf> {
199 self.variant_file_path(alias, "llms.txt")
200 }
201
202 pub fn llms_json_path(&self, alias: &str) -> Result<PathBuf> {
204 self.flavor_json_path(alias, "llms")
205 }
206
207 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 pub fn index_dir(&self, alias: &str) -> Result<PathBuf> {
215 Ok(self.tool_dir(alias)?.join(".index"))
216 }
217
218 pub fn archive_dir(&self, alias: &str) -> Result<PathBuf> {
220 Ok(self.tool_dir(alias)?.join(".archive"))
221 }
222
223 pub fn metadata_path(&self, alias: &str) -> Result<PathBuf> {
225 self.metadata_path_for_flavor(alias, "llms")
226 }
227
228 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 pub fn anchors_map_path(&self, alias: &str) -> Result<PathBuf> {
236 Ok(self.tool_dir(alias)?.join("anchors.json"))
237 }
238
239 pub fn save_llms_txt(&self, alias: &str, content: &str) -> Result<()> {
241 self.save_flavor_content(alias, "llms.txt", content)
242 }
243
244 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 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 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 pub fn save_llms_json(&self, alias: &str, data: &LlmsJson) -> Result<()> {
282 self.save_flavor_json(alias, "llms", data)
283 }
284
285 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 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 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 pub fn save_source_metadata(&self, alias: &str, source: &Source) -> Result<()> {
331 self.save_source_metadata_for_flavor(alias, "llms", source)
332 }
333
334 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 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 #[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 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 pub fn load_source_metadata(&self, alias: &str) -> Result<Option<Source>> {
377 self.load_source_metadata_for_flavor(alias, "llms")
378 }
379
380 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 #[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 #[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 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 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 #[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 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 let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%SZ");
499
500 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 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 fn check_and_migrate_old_cache(new_root: &Path) {
538 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 if old_root.exists() && old_root.is_dir() {
546 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 if new_root.exists()
564 && fs::read_dir(new_root)
565 .map(|mut e| e.next().is_some())
566 .unwrap_or(false)
567 {
568 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 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 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 fn migrate_directory(from: &Path, to: &Path) -> Result<()> {
605 fs::create_dir_all(to)
607 .map_err(|e| Error::Storage(format!("Failed to create migration target: {e}")))?;
608
609 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 Self::migrate_directory(&path, &target_path)?;
622 } else {
623 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#[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 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 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 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 assert!(storage.tool_dir("").is_err());
734
735 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 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 storage
765 .save_llms_txt("react", content)
766 .expect("Should save llms.txt");
767
768 assert!(
770 storage
771 .llms_txt_path("react")
772 .expect("Should get path")
773 .exists()
774 );
775
776 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 storage
791 .save_llms_json("react", &llms_json)
792 .expect("Should save llms.json");
793
794 assert!(
796 storage
797 .llms_json_path("react")
798 .expect("Should get path")
799 .exists()
800 );
801
802 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 assert!(!storage.exists("react"));
820
821 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 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 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 let hidden_dir = temp_dir.path().join(".hidden");
864 fs::create_dir(&hidden_dir).expect("Should create hidden dir");
865
866 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 storage
883 .ensure_tool_dir("incomplete")
884 .expect("Should create dir");
885
886 storage
888 .save_llms_txt("incomplete", "# Test content")
889 .expect("Should save txt");
890
891 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 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 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 storage.archive("test").expect("Should archive");
952
953 let archive_dir = storage.archive_dir("test").expect("Should get archive dir");
955 assert!(archive_dir.exists());
956
957 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); 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 let result = storage.archive("nonexistent");
988 assert!(result.is_ok());
989
990 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 storage
1016 .save_llms_json("test", &original)
1017 .expect("Should save");
1018 let loaded = storage.load_llms_json("test").expect("Should load");
1019
1020 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 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 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}