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