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!("{}.md", key));
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!("{}.md", key));
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!("{}.md", key));
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!("{}.md", key));
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
266            .store(key, &data, &format!("Key-Value: {}", key))
267    }
268
269    pub fn get(&self, key: &str) -> Result<String> {
270        let data: IndexMap<String, String> = self.storage.load(key)?;
271        data.get("value")
272            .cloned()
273            .ok_or_else(|| anyhow::anyhow!("Value not found for key: {}", key))
274    }
275
276    pub fn delete(&self, key: &str) -> Result<()> {
277        self.storage.delete(key)
278    }
279
280    pub fn list_keys(&self) -> Result<Vec<String>> {
281        self.storage.list()
282    }
283}
284
285/// Simple project metadata storage
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct ProjectData {
288    pub name: String,
289    pub description: Option<String>,
290    pub version: String,
291    pub tags: Vec<String>,
292    pub metadata: IndexMap<String, String>,
293}
294
295impl ProjectData {
296    pub fn new(name: &str) -> Self {
297        Self {
298            name: name.to_string(),
299            description: None,
300            version: "1.0.0".to_string(),
301            tags: vec![],
302            metadata: IndexMap::new(),
303        }
304    }
305}
306
307/// Project storage using markdown
308#[derive(Clone)]
309pub struct ProjectStorage {
310    storage: MarkdownStorage,
311}
312
313impl ProjectStorage {
314    pub fn new(storage_dir: PathBuf) -> Self {
315        Self {
316            storage: MarkdownStorage::new(storage_dir),
317        }
318    }
319
320    pub fn init(&self) -> Result<()> {
321        self.storage.init()
322    }
323
324    pub fn save_project(&self, project: &ProjectData) -> Result<()> {
325        self.storage.store(
326            &project.name,
327            project,
328            &format!("Project: {}", project.name),
329        )
330    }
331
332    pub fn load_project(&self, name: &str) -> Result<ProjectData> {
333        self.storage.load(name)
334    }
335
336    pub fn list_projects(&self) -> Result<Vec<String>> {
337        self.storage.list()
338    }
339
340    pub fn delete_project(&self, name: &str) -> Result<()> {
341        self.storage.delete(name)
342    }
343
344    pub fn storage_dir(&self) -> &Path {
345        &self.storage.storage_dir
346    }
347}
348
349/// Simple project manager that orchestrates project metadata persistence.
350#[derive(Clone)]
351pub struct SimpleProjectManager {
352    storage: ProjectStorage,
353    workspace_root: PathBuf,
354    project_root: PathBuf,
355}
356
357impl SimpleProjectManager {
358    /// Construct a project manager that stores metadata under
359    /// `<workspace_root>/.vtcode/projects`.
360    pub fn new(workspace_root: PathBuf) -> Self {
361        let project_root = workspace_root.join(".vtcode").join("projects");
362        Self::with_project_root(workspace_root, project_root)
363    }
364
365    /// Construct a manager with a caller-supplied project storage root.
366    pub fn with_project_root(workspace_root: PathBuf, project_root: PathBuf) -> Self {
367        let storage = ProjectStorage::new(project_root.clone());
368        Self {
369            storage,
370            workspace_root,
371            project_root,
372        }
373    }
374
375    /// Initialize the project manager
376    pub fn init(&self) -> Result<()> {
377        self.storage.init()
378    }
379
380    /// Create a new project
381    pub fn create_project(&self, name: &str, description: Option<&str>) -> Result<()> {
382        let mut project = ProjectData::new(name);
383        project.description = description.map(|s| s.to_string());
384
385        self.storage.save_project(&project)?;
386        Ok(())
387    }
388
389    /// Load a project by name
390    pub fn load_project(&self, name: &str) -> Result<ProjectData> {
391        self.storage.load_project(name)
392    }
393
394    /// List all projects
395    pub fn list_projects(&self) -> Result<Vec<String>> {
396        self.storage.list_projects()
397    }
398
399    /// Delete a project
400    pub fn delete_project(&self, name: &str) -> Result<()> {
401        self.storage.delete_project(name)
402    }
403
404    /// Update project metadata
405    pub fn update_project(&self, project: &ProjectData) -> Result<()> {
406        self.storage.save_project(project)
407    }
408
409    /// Get project data directory
410    pub fn project_data_dir(&self, project_name: &str) -> PathBuf {
411        self.project_root.join(project_name)
412    }
413
414    /// Get project config directory
415    pub fn config_dir(&self, project_name: &str) -> PathBuf {
416        self.project_data_dir(project_name).join("config")
417    }
418
419    /// Get project cache directory
420    pub fn cache_dir(&self, project_name: &str) -> PathBuf {
421        self.project_data_dir(project_name).join("cache")
422    }
423
424    /// Get workspace root
425    pub fn workspace_root(&self) -> &Path {
426        &self.workspace_root
427    }
428
429    /// Return the root directory backing project metadata.
430    pub fn project_root(&self) -> &Path {
431        &self.project_root
432    }
433
434    /// Check if project exists
435    pub fn project_exists(&self, name: &str) -> bool {
436        self.storage
437            .list_projects()
438            .map(|projects| projects.contains(&name.to_string()))
439            .unwrap_or(false)
440    }
441
442    /// Get project info as simple text
443    pub fn get_project_info(&self, name: &str) -> Result<String> {
444        let project = self.load_project(name)?;
445
446        let mut info = format!("Project: {}\n", project.name);
447        if let Some(desc) = &project.description {
448            info.push_str(&format!("Description: {}\n", desc));
449        }
450        info.push_str(&format!("Version: {}\n", project.version));
451        info.push_str(&format!("Tags: {}\n", project.tags.join(", ")));
452
453        if !project.metadata.is_empty() {
454            info.push_str("\nMetadata:\n");
455            for (key, value) in &project.metadata {
456                info.push_str(&format!("  {}: {}\n", key, value));
457            }
458        }
459
460        Ok(info)
461    }
462
463    /// Simple project identification from current directory
464    pub fn identify_current_project(&self) -> Result<String> {
465        let project_file = self.workspace_root.join(".vtcode-project");
466        if project_file.exists() {
467            let content = fs::read_to_string(&project_file)?;
468            return Ok(content.trim().to_string());
469        }
470
471        self.workspace_root
472            .file_name()
473            .and_then(|name| name.to_str())
474            .map(|name| name.to_string())
475            .ok_or_else(|| anyhow::anyhow!("Could not determine project name from directory"))
476    }
477
478    /// Set current project
479    pub fn set_current_project(&self, name: &str) -> Result<()> {
480        let project_file = self.workspace_root.join(".vtcode-project");
481        fs::write(project_file, name)?;
482        Ok(())
483    }
484}
485
486/// Simple cache using file system
487pub struct SimpleCache {
488    cache_dir: PathBuf,
489}
490
491impl SimpleCache {
492    /// Create a new simple cache
493    pub fn new(cache_dir: PathBuf) -> Self {
494        Self { cache_dir }
495    }
496
497    /// Initialize cache directory
498    pub fn init(&self) -> Result<()> {
499        fs::create_dir_all(&self.cache_dir)?;
500        Ok(())
501    }
502
503    /// Store data in cache
504    pub fn store(&self, key: &str, data: &str) -> Result<()> {
505        let file_path = self.cache_dir.join(format!("{}.txt", key));
506        write_with_lock(&file_path, data.as_bytes())
507    }
508
509    /// Load data from cache
510    pub fn load(&self, key: &str) -> Result<String> {
511        let file_path = self.cache_dir.join(format!("{}.txt", key));
512        read_with_shared_lock(&file_path).map_err(|err| {
513            if err
514                .downcast_ref::<std::io::Error>()
515                .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::NotFound)
516            {
517                anyhow::anyhow!("Cache key '{}' not found", key)
518            } else {
519                err
520            }
521        })
522    }
523
524    /// Check if cache entry exists
525    pub fn exists(&self, key: &str) -> bool {
526        let file_path = self.cache_dir.join(format!("{}.txt", key));
527        file_path.exists()
528    }
529
530    /// Clear cache
531    pub fn clear(&self) -> Result<()> {
532        for entry in fs::read_dir(&self.cache_dir)? {
533            let entry = entry?;
534            if entry.path().is_file() {
535                fs::remove_file(entry.path())?;
536            }
537        }
538        Ok(())
539    }
540
541    /// List cache entries
542    pub fn list(&self) -> Result<Vec<String>> {
543        let mut entries = Vec::new();
544        for entry in fs::read_dir(&self.cache_dir)? {
545            let entry = entry?;
546            if let Some(name) = entry
547                .path()
548                .file_stem()
549                .and_then(|file_name| file_name.to_str())
550            {
551                entries.push(name.to_string());
552            }
553        }
554        Ok(entries)
555    }
556}