Skip to main content

vtcode_indexer/
markdown_store.rs

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