Skip to main content

qex_core/index/
storage.rs

1use crate::index::IndexResult;
2use anyhow::{Context, Result};
3use sha2::{Digest, Sha256};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// Manages storage layout for a project's index data
8///
9/// Layout:
10/// ```text
11/// ~/.qex/projects/{project_name}_{hash8}/
12///   ├── project_info.json
13///   ├── tantivy/           # BM25 index
14///   ├── snapshot.json      # Merkle DAG
15///   ├── snapshot_metadata.json
16///   └── stats.json
17/// ```
18pub struct ProjectStorage {
19    base_dir: PathBuf,
20}
21
22impl ProjectStorage {
23    /// Create storage for a project path
24    pub fn for_project(project_path: &Path) -> Result<Self> {
25        let normalized = project_path
26            .canonicalize()
27            .unwrap_or_else(|_| project_path.to_path_buf());
28        let path_str = normalized.to_string_lossy();
29
30        // Project name from directory
31        let project_name = normalized
32            .file_name()
33            .and_then(|n| n.to_str())
34            .unwrap_or("unknown");
35
36        // Hash for uniqueness
37        let hash = {
38            let mut hasher = Sha256::new();
39            hasher.update(path_str.as_bytes());
40            format!("{:x}", hasher.finalize())[..8].to_string()
41        };
42
43        let dir_name = format!("{}_{}", sanitize_name(project_name), hash);
44
45        let base_dir = qex_home()?.join("projects").join(dir_name);
46        fs::create_dir_all(&base_dir)
47            .context("Failed to create project storage directory")?;
48
49        // Write project info
50        let info_path = base_dir.join("project_info.json");
51        if !info_path.exists() {
52            let info = serde_json::json!({
53                "project_path": path_str.as_ref(),
54                "project_name": project_name,
55            });
56            fs::write(&info_path, serde_json::to_string_pretty(&info)?)?;
57        }
58
59        Ok(Self { base_dir })
60    }
61
62    pub fn base_dir(&self) -> &Path {
63        &self.base_dir
64    }
65
66    pub fn tantivy_dir(&self) -> PathBuf {
67        self.base_dir.join("tantivy")
68    }
69
70    pub fn dense_dir(&self) -> PathBuf {
71        self.base_dir.join("dense")
72    }
73
74    /// Check if an index exists
75    pub fn has_index(&self) -> bool {
76        self.tantivy_dir().join("meta.json").exists()
77    }
78
79    /// Save index stats
80    pub fn save_stats(&self, result: &IndexResult) -> Result<()> {
81        let stats_path = self.base_dir.join("stats.json");
82        let json = serde_json::to_string_pretty(result)?;
83        fs::write(stats_path, json)?;
84        Ok(())
85    }
86
87    /// Load index stats
88    pub fn load_stats(&self) -> Result<Option<IndexResult>> {
89        let stats_path = self.base_dir.join("stats.json");
90        if !stats_path.exists() {
91            return Ok(None);
92        }
93        let json = fs::read_to_string(stats_path)?;
94        let stats: IndexResult = serde_json::from_str(&json)?;
95        Ok(Some(stats))
96    }
97
98    /// Clear snapshot and stats (keeps tantivy index)
99    pub fn clear(&self) -> Result<()> {
100        let snapshot = self.base_dir.join("snapshot.json");
101        let metadata = self.base_dir.join("snapshot_metadata.json");
102        let stats = self.base_dir.join("stats.json");
103
104        for path in [snapshot, metadata, stats] {
105            if path.exists() {
106                fs::remove_file(path)?;
107            }
108        }
109        Ok(())
110    }
111
112    /// Clear everything including tantivy index
113    pub fn clear_all(&self) -> Result<()> {
114        if self.base_dir.exists() {
115            fs::remove_dir_all(&self.base_dir)?;
116        }
117        Ok(())
118    }
119}
120
121/// Get the qex home directory
122fn qex_home() -> Result<PathBuf> {
123    let home = dirs::home_dir().context("Could not determine home directory")?;
124    Ok(home.join(".qex"))
125}
126
127/// Sanitize a project name for use in filesystem paths
128fn sanitize_name(name: &str) -> String {
129    name.chars()
130        .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
131        .collect()
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use tempfile::TempDir;
138
139    #[test]
140    fn test_project_storage_creation() {
141        let dir = TempDir::new().unwrap();
142        let storage = ProjectStorage::for_project(dir.path()).unwrap();
143        assert!(storage.base_dir().exists());
144        assert!(storage.base_dir().join("project_info.json").exists());
145    }
146
147    #[test]
148    fn test_sanitize_name() {
149        assert_eq!(sanitize_name("my-project"), "my-project");
150        assert_eq!(sanitize_name("my project!"), "my_project_");
151        assert_eq!(sanitize_name("code_context"), "code_context");
152    }
153}