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