next_plaid_cli/index/
paths.rs

1//! Centralized index storage paths following XDG Base Directory Specification
2//!
3//! Index storage location:
4//! - Linux: ~/.local/share/plaid/indices/
5//! - macOS: ~/Library/Application Support/plaid/indices/
6//! - Windows: C:\Users\{user}\AppData\Roaming\plaid\indices\
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use xxhash_rust::xxh3::xxh3_64;
14
15const STATE_FILE: &str = "state.json";
16const PROJECT_FILE: &str = "project.json";
17const INDEX_SUBDIR: &str = "index";
18
19/// Metadata about the project stored alongside the index
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ProjectMetadata {
22    /// Canonical path to the project directory
23    pub project_path: PathBuf,
24    /// Project name (directory name)
25    pub project_name: String,
26}
27
28impl ProjectMetadata {
29    pub fn new(project_path: &Path) -> Self {
30        let project_name = project_path
31            .file_name()
32            .map(|n| n.to_string_lossy().to_string())
33            .unwrap_or_else(|| "project".to_string());
34
35        Self {
36            project_path: project_path.to_path_buf(),
37            project_name,
38        }
39    }
40
41    pub fn load(index_dir: &Path) -> Result<Self> {
42        let path = index_dir.join(PROJECT_FILE);
43        let content = fs::read_to_string(&path)
44            .with_context(|| format!("Failed to read {}", path.display()))?;
45        Ok(serde_json::from_str(&content)?)
46    }
47
48    pub fn save(&self, index_dir: &Path) -> Result<()> {
49        fs::create_dir_all(index_dir)?;
50        let path = index_dir.join(PROJECT_FILE);
51        let content = serde_json::to_string_pretty(self)?;
52        fs::write(&path, content)?;
53        Ok(())
54    }
55}
56
57/// Get the base plaid data directory (XDG_DATA_HOME/plaid or platform equivalent)
58pub fn get_plaid_data_dir() -> Result<PathBuf> {
59    let data_dir = dirs::data_dir().context("Could not determine data directory")?;
60    Ok(data_dir.join("plaid").join("indices"))
61}
62
63/// Compute the index directory name for a project path
64/// Format: {project_name}-{first 8 hex chars of xxh3_64 hash}
65fn compute_index_dir_name(project_path: &Path) -> String {
66    let path_str = project_path.to_string_lossy();
67    let hash = xxh3_64(path_str.as_bytes());
68    let hash_prefix = format!("{:08x}", hash).chars().take(8).collect::<String>();
69
70    let project_name = project_path
71        .file_name()
72        .map(|n| n.to_string_lossy().to_string())
73        .unwrap_or_else(|| "project".to_string());
74
75    // Sanitize project name (remove characters that might cause issues in filenames)
76    let sanitized_name: String = project_name
77        .chars()
78        .map(|c| {
79            if c.is_alphanumeric() || c == '-' || c == '_' {
80                c
81            } else {
82                '_'
83            }
84        })
85        .collect();
86
87    format!("{}-{}", sanitized_name, hash_prefix)
88}
89
90/// Get the index directory for a project path
91/// Creates the directory structure if it doesn't exist
92pub fn get_index_dir_for_project(project_path: &Path) -> Result<PathBuf> {
93    let base_dir = get_plaid_data_dir()?;
94    let dir_name = compute_index_dir_name(project_path);
95    Ok(base_dir.join(dir_name))
96}
97
98/// Find an existing index for a project path
99/// Returns None if no index exists
100pub fn find_index_for_project(project_path: &Path) -> Result<Option<PathBuf>> {
101    let index_dir = get_index_dir_for_project(project_path)?;
102
103    // Check if the index directory exists and has valid metadata
104    let metadata_path = index_dir.join(INDEX_SUBDIR).join("metadata.json");
105    if metadata_path.exists() {
106        // Verify the project path matches
107        if let Ok(meta) = ProjectMetadata::load(&index_dir) {
108            if meta.project_path == project_path {
109                return Ok(Some(index_dir));
110            }
111        }
112        // Index exists but project path doesn't match (hash collision)
113        // This is extremely rare with xxh3_64, but handle it gracefully
114        return Ok(Some(index_dir));
115    }
116
117    Ok(None)
118}
119
120/// Check if an index exists for the given project
121pub fn index_exists(project_path: &Path) -> bool {
122    matches!(find_index_for_project(project_path), Ok(Some(_)))
123}
124
125/// Information about a discovered parent index
126#[derive(Debug, Clone)]
127pub struct ParentIndexInfo {
128    /// Path to the parent project's index directory
129    pub index_dir: PathBuf,
130    /// The parent project's root path
131    pub project_path: PathBuf,
132    /// Relative path from parent project root to the search directory
133    pub relative_subdir: PathBuf,
134}
135
136/// Find if the given path is a subdirectory of any existing indexed project.
137/// Returns the most specific (longest-matching) parent index if found.
138pub fn find_parent_index(search_path: &Path) -> Result<Option<ParentIndexInfo>> {
139    let data_dir = get_plaid_data_dir()?;
140
141    if !data_dir.exists() {
142        return Ok(None);
143    }
144
145    let mut best_match: Option<ParentIndexInfo> = None;
146    let mut best_depth = 0;
147
148    for entry in fs::read_dir(&data_dir)?.filter_map(|e| e.ok()) {
149        let index_dir = entry.path();
150        if !index_dir.is_dir() {
151            continue;
152        }
153
154        // Try to load project metadata
155        if let Ok(meta) = ProjectMetadata::load(&index_dir) {
156            // Check if search_path starts with this project's path
157            // but is NOT the same path (must be a subdirectory)
158            if search_path != meta.project_path {
159                if let Ok(relative) = search_path.strip_prefix(&meta.project_path) {
160                    // Prefer the most specific (longest) parent path
161                    let depth = meta.project_path.components().count();
162                    if depth > best_depth {
163                        best_depth = depth;
164                        best_match = Some(ParentIndexInfo {
165                            index_dir,
166                            project_path: meta.project_path,
167                            relative_subdir: relative.to_path_buf(),
168                        });
169                    }
170                }
171            }
172        }
173    }
174
175    Ok(best_match)
176}
177
178/// Get the path to the state.json file within an index directory
179pub fn get_state_path(index_dir: &Path) -> PathBuf {
180    index_dir.join(STATE_FILE)
181}
182
183/// Get the path to the vector index within an index directory
184pub fn get_vector_index_path(index_dir: &Path) -> PathBuf {
185    index_dir.join(INDEX_SUBDIR)
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn test_compute_index_dir_name() {
194        let path = PathBuf::from("/Users/foo/myproject");
195        let name = compute_index_dir_name(&path);
196        // Should be format: myproject-{8 hex chars}
197        assert!(name.starts_with("myproject-"));
198        assert_eq!(name.len(), "myproject-".len() + 8);
199    }
200
201    #[test]
202    fn test_compute_index_dir_name_with_special_chars() {
203        let path = PathBuf::from("/Users/foo/my project (1)");
204        let name = compute_index_dir_name(&path);
205        // Special chars should be replaced with underscores
206        assert!(name.starts_with("my_project__1_-"));
207    }
208
209    #[test]
210    fn test_different_paths_different_hashes() {
211        let path1 = PathBuf::from("/Users/foo/project1");
212        let path2 = PathBuf::from("/Users/foo/project2");
213        let name1 = compute_index_dir_name(&path1);
214        let name2 = compute_index_dir_name(&path2);
215        assert_ne!(name1, name2);
216    }
217}