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