Skip to main content

vtcode_indexer/
markdown_store.rs

1//! Markdown-backed storage utilities extracted from VT Code.
2//!
3//! This module provides lightweight persistence helpers that serialize
4//! structured data into Markdown files with embedded JSON and YAML blocks.
5//! It also exposes simple project and cache managers built on top of the
6//! markdown storage abstraction so command-line tools can persist
7//! human-readable state without requiring a database.
8
9use std::fs::{self, OpenOptions};
10use std::io::{Read, Write};
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use fs2::FileExt;
15use indexmap::IndexMap;
16use serde::{Deserialize, Serialize};
17
18/// Simple markdown storage manager
19#[derive(Clone)]
20pub struct MarkdownStorage {
21    storage_dir: PathBuf,
22}
23
24impl MarkdownStorage {
25    /// Create a new markdown storage instance rooted at `storage_dir`.
26    fn new(storage_dir: PathBuf) -> Self {
27        Self { storage_dir }
28    }
29
30    /// Initialize storage directory
31    fn init(&self) -> Result<()> {
32        fs::create_dir_all(&self.storage_dir)?;
33        Ok(())
34    }
35
36    /// Store data as markdown
37    fn store<T: Serialize>(&self, key: &str, data: &T, title: &str) -> Result<()> {
38        let file_path = self.storage_dir.join(format!("{key}.md"));
39        let markdown = self.serialize_to_markdown(data, title)?;
40        write_with_lock(&file_path, markdown.as_bytes())
41    }
42
43    /// Load data from markdown
44    pub fn load<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Result<T> {
45        let file_path = self.storage_dir.join(format!("{key}.md"));
46        let content = read_with_shared_lock(&file_path)?;
47        self.deserialize_from_markdown(&content)
48    }
49
50    /// List all stored items
51    pub fn list(&self) -> Result<Vec<String>> {
52        let mut items = Vec::new();
53
54        for entry in fs::read_dir(&self.storage_dir)? {
55            let entry = entry?;
56            if let Some(name) = entry.path().file_stem().and_then(|file_name| file_name.to_str()) {
57                items.push(name.to_string());
58            }
59        }
60
61        Ok(items)
62    }
63
64    /// Delete stored item
65    pub fn delete(&self, key: &str) -> Result<()> {
66        let file_path = self.storage_dir.join(format!("{key}.md"));
67        if file_path.exists() {
68            // Try to obtain an exclusive lock before removing the file so
69            // concurrent readers or writers can finish gracefully.
70            if let Ok(file) = OpenOptions::new().read(true).write(true).open(&file_path) {
71                let _ = file.lock_exclusive();
72                // Explicit drop to release the lock prior to removal.
73                drop(file);
74            }
75
76            // Removing a file that was concurrently deleted is not an error -
77            // treat it as best-effort cleanup.
78            match fs::remove_file(&file_path) {
79                Ok(_) => {}
80                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
81                Err(err) => {
82                    return Err(err)
83                        .with_context(|| format!("Failed to delete markdown file at {}", file_path.display()));
84                }
85            }
86        }
87        Ok(())
88    }
89
90    /// Check if item exists
91    pub fn exists(&self, key: &str) -> bool {
92        let file_path = self.storage_dir.join(format!("{key}.md"));
93        file_path.exists()
94    }
95
96    fn serialize_to_markdown<T: Serialize>(&self, data: &T, title: &str) -> Result<String> {
97        let json = serde_json::to_string_pretty(data)?;
98        let yaml = serde_saphyr::to_string(data)?;
99
100        let markdown = format!(
101            "# {}\n\n\
102            ## JSON\n\n\
103            ```json\n\
104            {}\n\
105            ```\n\n\
106            ## YAML\n\n\
107            ```yaml\n\
108            {}\n\
109            ```\n\n\
110            ## Raw Data\n\n\
111            {}\n",
112            title,
113            json,
114            yaml,
115            self.format_raw_data(data)
116        );
117
118        Ok(markdown)
119    }
120
121    fn deserialize_from_markdown<T: for<'de> Deserialize<'de>>(&self, content: &str) -> Result<T> {
122        if let Some(json_block) = self.extract_code_block(content, "json") {
123            return serde_json::from_str(json_block).context("Failed to parse JSON from markdown");
124        }
125
126        if let Some(yaml_block) = self.extract_code_block(content, "yaml") {
127            return serde_saphyr::from_str(yaml_block).context("Failed to parse YAML from markdown");
128        }
129
130        Err(anyhow::anyhow!("No valid JSON or YAML found in markdown"))
131    }
132
133    fn extract_code_block<'a>(&self, content: &'a str, language: &str) -> Option<&'a str> {
134        let start_pattern = format!("```{language}");
135        let end_pattern = "```";
136
137        if let Some(start_idx) = content.find(&start_pattern) {
138            let code_start = start_idx + start_pattern.len();
139            if let Some(end_idx) = content[code_start..].find(end_pattern) {
140                let code_end = code_start + end_idx;
141                return Some(content[code_start..code_end].trim());
142            }
143        }
144
145        None
146    }
147
148    fn format_raw_data<T: Serialize>(&self, data: &T) -> String {
149        match serde_json::to_value(data) {
150            Ok(serde_json::Value::Object(map)) => {
151                let mut lines = Vec::with_capacity(map.len());
152                for (key, value) in map {
153                    lines.push(format!("- **{}**: {}", key, self.format_value(&value)));
154                }
155                lines.join("\n")
156            }
157            _ => "Complex data structure".to_string(),
158        }
159    }
160
161    fn format_value(&self, value: &serde_json::Value) -> String {
162        match value {
163            serde_json::Value::String(s) => format!("\"{s}\""),
164            serde_json::Value::Number(n) => n.to_string(),
165            serde_json::Value::Bool(b) => b.to_string(),
166            serde_json::Value::Array(arr) => format!("[{} items]", arr.len()),
167            serde_json::Value::Object(obj) => format!("{{{} fields}}", obj.len()),
168            serde_json::Value::Null => "null".to_string(),
169        }
170    }
171}
172
173fn write_with_lock(path: &Path, data: &[u8]) -> Result<()> {
174    if let Some(parent) = path.parent() {
175        fs::create_dir_all(parent)
176            .with_context(|| format!("Failed to ensure parent directory exists for {}", path.display()))?;
177    }
178
179    let mut file = OpenOptions::new()
180        .create(true)
181        .write(true)
182        .truncate(false)
183        .open(path)
184        .with_context(|| format!("Failed to open file at {}", path.display()))?;
185
186    FileExt::lock_exclusive(&file)
187        .with_context(|| format!("Failed to acquire exclusive lock for {}", path.display()))?;
188
189    file.set_len(0)
190        .with_context(|| format!("Failed to truncate file at {} while holding exclusive lock", path.display()))?;
191
192    file.write_all(data)
193        .with_context(|| format!("Failed to write file content to {} while holding exclusive lock", path.display()))?;
194
195    file.sync_all()
196        .with_context(|| format!("Failed to sync file at {} after writing with exclusive lock", path.display()))?;
197
198    FileExt::unlock(&file).with_context(|| format!("Failed to release exclusive lock for {}", path.display()))
199}
200
201fn read_with_shared_lock(path: &Path) -> Result<String> {
202    let mut file = OpenOptions::new()
203        .read(true)
204        .open(path)
205        .with_context(|| format!("Failed to open file at {}", path.display()))?;
206
207    FileExt::lock_shared(&file).with_context(|| format!("Failed to acquire shared lock for {}", path.display()))?;
208
209    let mut content = String::new();
210    file.read_to_string(&mut content)
211        .with_context(|| format!("Failed to read file content from {} while holding shared lock", path.display()))?;
212
213    FileExt::unlock(&file).with_context(|| format!("Failed to release shared lock for {}", path.display()))?;
214
215    Ok(content)
216}
217
218/// Simple key-value storage using markdown
219pub struct SimpleKVStorage {
220    storage: MarkdownStorage,
221}
222
223impl SimpleKVStorage {
224    pub fn new(storage_dir: PathBuf) -> Self {
225        Self { storage: MarkdownStorage::new(storage_dir) }
226    }
227
228    pub fn init(&self) -> Result<()> {
229        self.storage.init()
230    }
231
232    pub fn put(&self, key: &str, value: &str) -> Result<()> {
233        let data = IndexMap::from([("value".to_string(), value.to_string())]);
234        self.storage.store(key, &data, &format!("Key-Value: {key}"))
235    }
236
237    pub fn get(&self, key: &str) -> Result<String> {
238        let data: IndexMap<String, String> = self.storage.load(key)?;
239        data.get("value")
240            .cloned()
241            .ok_or_else(|| anyhow::anyhow!("Value not found for key: {key}"))
242    }
243
244    pub fn delete(&self, key: &str) -> Result<()> {
245        self.storage.delete(key)
246    }
247
248    pub fn list_keys(&self) -> Result<Vec<String>> {
249        self.storage.list()
250    }
251}
252
253/// Simple project metadata storage
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct ProjectData {
256    name: String,
257    pub description: Option<String>,
258    version: String,
259    tags: Vec<String>,
260    metadata: IndexMap<String, String>,
261}
262
263impl ProjectData {
264    pub fn new(name: &str) -> Self {
265        Self {
266            name: name.to_string(),
267            description: None,
268            version: "1.0.0".to_string(),
269            tags: vec![],
270            metadata: IndexMap::new(),
271        }
272    }
273}
274
275/// Project storage using markdown
276#[derive(Clone)]
277pub struct ProjectStorage {
278    storage: MarkdownStorage,
279}
280
281impl ProjectStorage {
282    fn new(storage_dir: PathBuf) -> Self {
283        Self { storage: MarkdownStorage::new(storage_dir) }
284    }
285
286    fn init(&self) -> Result<()> {
287        self.storage.init()
288    }
289
290    fn save_project(&self, project: &ProjectData) -> Result<()> {
291        self.storage
292            .store(&project.name, project, &format!("Project: {}", project.name))
293    }
294
295    pub fn load_project(&self, name: &str) -> Result<ProjectData> {
296        self.storage.load(name)
297    }
298
299    pub fn list_projects(&self) -> Result<Vec<String>> {
300        self.storage.list()
301    }
302
303    pub fn delete_project(&self, name: &str) -> Result<()> {
304        self.storage.delete(name)
305    }
306
307    pub fn storage_dir(&self) -> &Path {
308        &self.storage.storage_dir
309    }
310}
311
312/// Simple project manager that orchestrates project metadata persistence.
313#[derive(Clone)]
314pub struct SimpleProjectManager {
315    storage: ProjectStorage,
316    workspace_root: PathBuf,
317    project_root: PathBuf,
318}
319
320impl SimpleProjectManager {
321    /// Construct a project manager that stores metadata under
322    /// `<workspace_root>/.vtcode/projects`.
323    pub fn new(workspace_root: PathBuf) -> Self {
324        let project_root = workspace_root.join(".vtcode").join("projects");
325        Self::with_project_root(workspace_root, project_root)
326    }
327
328    /// Construct a manager with a caller-supplied project storage root.
329    fn with_project_root(workspace_root: PathBuf, project_root: PathBuf) -> Self {
330        let storage = ProjectStorage::new(project_root.clone());
331        Self { storage, workspace_root, project_root }
332    }
333
334    /// Initialize the project manager
335    pub fn init(&self) -> Result<()> {
336        self.storage.init()
337    }
338
339    /// Create a new project
340    pub fn create_project(&self, name: &str, description: Option<&str>) -> Result<()> {
341        let mut project = ProjectData::new(name);
342        project.description = description.map(|s| s.to_string());
343
344        self.storage.save_project(&project)?;
345        Ok(())
346    }
347
348    /// Load a project by name
349    pub fn load_project(&self, name: &str) -> Result<ProjectData> {
350        self.storage.load_project(name)
351    }
352
353    /// List all projects
354    pub fn list_projects(&self) -> Result<Vec<String>> {
355        self.storage.list_projects()
356    }
357
358    /// Delete a project
359    pub fn delete_project(&self, name: &str) -> Result<()> {
360        self.storage.delete_project(name)
361    }
362
363    /// Update project metadata
364    pub fn update_project(&self, project: &ProjectData) -> Result<()> {
365        self.storage.save_project(project)
366    }
367
368    /// Get project data directory
369    pub fn project_data_dir(&self, project_name: &str) -> PathBuf {
370        self.project_root.join(project_name)
371    }
372
373    /// Get project config directory
374    pub fn config_dir(&self, project_name: &str) -> PathBuf {
375        self.project_data_dir(project_name).join("config")
376    }
377
378    /// Get project cache directory
379    pub fn cache_dir(&self, project_name: &str) -> PathBuf {
380        self.project_data_dir(project_name).join("cache")
381    }
382
383    /// Get workspace root
384    pub fn workspace_root(&self) -> &Path {
385        &self.workspace_root
386    }
387
388    /// Return the root directory backing project metadata.
389    pub fn project_root(&self) -> &Path {
390        &self.project_root
391    }
392
393    /// Check if project exists
394    pub fn project_exists(&self, name: &str) -> bool {
395        self.storage
396            .list_projects()
397            .map(|projects| projects.contains(&name.to_string()))
398            .unwrap_or(false)
399    }
400
401    /// Get project info as simple text
402    pub fn get_project_info(&self, name: &str) -> Result<String> {
403        let project = self.load_project(name)?;
404
405        let mut info = format!("Project: {}\n", project.name);
406        if let Some(desc) = &project.description {
407            info.push_str(&format!("Description: {desc}\n"));
408        }
409        info.push_str(&format!("Version: {}\n", project.version));
410        info.push_str(&format!("Tags: {}\n", project.tags.join(", ")));
411
412        if !project.metadata.is_empty() {
413            info.push_str("\nMetadata:\n");
414            for (key, value) in &project.metadata {
415                info.push_str(&format!("  {key}: {value}\n"));
416            }
417        }
418
419        Ok(info)
420    }
421
422    /// Simple project identification from current directory
423    pub fn identify_current_project(&self) -> Result<String> {
424        let project_file = self.workspace_root.join(".vtcode-project");
425        if project_file.exists() {
426            let content = fs::read_to_string(&project_file)?;
427            return Ok(content.trim().to_string());
428        }
429
430        self.workspace_root
431            .file_name()
432            .and_then(|name| name.to_str())
433            .map(|name| name.to_string())
434            .ok_or_else(|| anyhow::anyhow!("Could not determine project name from directory"))
435    }
436
437    /// Set current project
438    pub fn set_current_project(&self, name: &str) -> Result<()> {
439        let project_file = self.workspace_root.join(".vtcode-project");
440        fs::write(project_file, name)?;
441        Ok(())
442    }
443}
444
445/// Simple cache using file system
446pub struct SimpleCache {
447    cache_dir: PathBuf,
448}
449
450impl SimpleCache {
451    /// Create a new simple cache
452    pub fn new(cache_dir: PathBuf) -> Self {
453        Self { cache_dir }
454    }
455
456    /// Initialize cache directory
457    pub fn init(&self) -> Result<()> {
458        fs::create_dir_all(&self.cache_dir)?;
459        Ok(())
460    }
461
462    /// Store data in cache
463    pub fn store(&self, key: &str, data: &str) -> Result<()> {
464        let file_path = self.cache_dir.join(format!("{key}.txt"));
465        write_with_lock(&file_path, data.as_bytes())
466    }
467
468    /// Load data from cache
469    pub fn load(&self, key: &str) -> Result<String> {
470        let file_path = self.cache_dir.join(format!("{key}.txt"));
471        read_with_shared_lock(&file_path).map_err(|err| {
472            if err
473                .downcast_ref::<std::io::Error>()
474                .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::NotFound)
475            {
476                anyhow::anyhow!("Cache key '{key}' not found")
477            } else {
478                err
479            }
480        })
481    }
482
483    /// Check if cache entry exists
484    pub fn exists(&self, key: &str) -> bool {
485        let file_path = self.cache_dir.join(format!("{key}.txt"));
486        file_path.exists()
487    }
488
489    /// Clear cache
490    pub fn clear(&self) -> Result<()> {
491        for entry in fs::read_dir(&self.cache_dir)? {
492            let entry = entry?;
493            if entry.path().is_file() {
494                fs::remove_file(entry.path())?;
495            }
496        }
497        Ok(())
498    }
499
500    /// List cache entries
501    pub fn list(&self) -> Result<Vec<String>> {
502        let mut entries = Vec::new();
503        for entry in fs::read_dir(&self.cache_dir)? {
504            let entry = entry?;
505            if let Some(name) = entry.path().file_stem().and_then(|file_name| file_name.to_str()) {
506                entries.push(name.to_string());
507            }
508        }
509        Ok(entries)
510    }
511}