Skip to main content

vtcode_markdown_store/
lib.rs

1//! Markdown-backed storage utilities extracted from VT Code.
2//!
3//! This crate 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::new();
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
248#[cfg(feature = "kv")]
249pub struct SimpleKVStorage {
250    storage: MarkdownStorage,
251}
252
253#[cfg(feature = "kv")]
254impl SimpleKVStorage {
255    pub fn new(storage_dir: PathBuf) -> Self {
256        Self {
257            storage: MarkdownStorage::new(storage_dir),
258        }
259    }
260
261    pub fn init(&self) -> Result<()> {
262        self.storage.init()
263    }
264
265    pub fn put(&self, key: &str, value: &str) -> Result<()> {
266        let data = IndexMap::from([("value".to_string(), value.to_string())]);
267        self.storage
268            .store(key, &data, &format!("Key-Value: {}", key))
269    }
270
271    pub fn get(&self, key: &str) -> Result<String> {
272        let data: IndexMap<String, String> = self.storage.load(key)?;
273        data.get("value")
274            .cloned()
275            .ok_or_else(|| anyhow::anyhow!("Value not found for key: {}", key))
276    }
277
278    pub fn delete(&self, key: &str) -> Result<()> {
279        self.storage.delete(key)
280    }
281
282    pub fn list_keys(&self) -> Result<Vec<String>> {
283        self.storage.list()
284    }
285}
286
287/// Simple project metadata storage
288#[cfg(feature = "projects")]
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct ProjectData {
291    pub name: String,
292    pub description: Option<String>,
293    pub version: String,
294    pub tags: Vec<String>,
295    pub metadata: IndexMap<String, String>,
296}
297
298#[cfg(feature = "projects")]
299impl ProjectData {
300    pub fn new(name: &str) -> Self {
301        Self {
302            name: name.to_string(),
303            description: None,
304            version: "1.0.0".to_string(),
305            tags: vec![],
306            metadata: IndexMap::new(),
307        }
308    }
309}
310
311/// Project storage using markdown
312#[cfg(feature = "projects")]
313#[derive(Clone)]
314pub struct ProjectStorage {
315    storage: MarkdownStorage,
316}
317
318#[cfg(feature = "projects")]
319impl ProjectStorage {
320    pub fn new(storage_dir: PathBuf) -> Self {
321        Self {
322            storage: MarkdownStorage::new(storage_dir),
323        }
324    }
325
326    pub fn init(&self) -> Result<()> {
327        self.storage.init()
328    }
329
330    pub fn save_project(&self, project: &ProjectData) -> Result<()> {
331        self.storage.store(
332            &project.name,
333            project,
334            &format!("Project: {}", project.name),
335        )
336    }
337
338    pub fn load_project(&self, name: &str) -> Result<ProjectData> {
339        self.storage.load(name)
340    }
341
342    pub fn list_projects(&self) -> Result<Vec<String>> {
343        self.storage.list()
344    }
345
346    pub fn delete_project(&self, name: &str) -> Result<()> {
347        self.storage.delete(name)
348    }
349
350    pub fn storage_dir(&self) -> &Path {
351        &self.storage.storage_dir
352    }
353}
354
355/// Simple project manager that orchestrates project metadata persistence.
356#[cfg(feature = "projects")]
357#[derive(Clone)]
358pub struct SimpleProjectManager {
359    storage: ProjectStorage,
360    workspace_root: PathBuf,
361    project_root: PathBuf,
362}
363
364#[cfg(feature = "projects")]
365impl SimpleProjectManager {
366    /// Construct a project manager that stores metadata under
367    /// `<workspace_root>/.vtcode/projects`.
368    pub fn new(workspace_root: PathBuf) -> Self {
369        let project_root = workspace_root.join(".vtcode").join("projects");
370        Self::with_project_root(workspace_root, project_root)
371    }
372
373    /// Construct a manager with a caller-supplied project storage root.
374    pub fn with_project_root(workspace_root: PathBuf, project_root: PathBuf) -> Self {
375        let storage = ProjectStorage::new(project_root.clone());
376        Self {
377            storage,
378            workspace_root,
379            project_root,
380        }
381    }
382
383    /// Initialize the project manager
384    pub fn init(&self) -> Result<()> {
385        self.storage.init()
386    }
387
388    /// Create a new project
389    pub fn create_project(&self, name: &str, description: Option<&str>) -> Result<()> {
390        let mut project = ProjectData::new(name);
391        project.description = description.map(|s| s.to_string());
392
393        self.storage.save_project(&project)?;
394        Ok(())
395    }
396
397    /// Load a project by name
398    pub fn load_project(&self, name: &str) -> Result<ProjectData> {
399        self.storage.load_project(name)
400    }
401
402    /// List all projects
403    pub fn list_projects(&self) -> Result<Vec<String>> {
404        self.storage.list_projects()
405    }
406
407    /// Delete a project
408    pub fn delete_project(&self, name: &str) -> Result<()> {
409        self.storage.delete_project(name)
410    }
411
412    /// Update project metadata
413    pub fn update_project(&self, project: &ProjectData) -> Result<()> {
414        self.storage.save_project(project)
415    }
416
417    /// Get project data directory
418    pub fn project_data_dir(&self, project_name: &str) -> PathBuf {
419        self.project_root.join(project_name)
420    }
421
422    /// Get project config directory
423    pub fn config_dir(&self, project_name: &str) -> PathBuf {
424        self.project_data_dir(project_name).join("config")
425    }
426
427    /// Get project cache directory
428    pub fn cache_dir(&self, project_name: &str) -> PathBuf {
429        self.project_data_dir(project_name).join("cache")
430    }
431
432    /// Get workspace root
433    pub fn workspace_root(&self) -> &Path {
434        &self.workspace_root
435    }
436
437    /// Return the root directory backing project metadata.
438    pub fn project_root(&self) -> &Path {
439        &self.project_root
440    }
441
442    /// Check if project exists
443    pub fn project_exists(&self, name: &str) -> bool {
444        self.storage
445            .list_projects()
446            .map(|projects| projects.contains(&name.to_string()))
447            .unwrap_or(false)
448    }
449
450    /// Get project info as simple text
451    pub fn get_project_info(&self, name: &str) -> Result<String> {
452        let project = self.load_project(name)?;
453
454        let mut info = format!("Project: {}\n", project.name);
455        if let Some(desc) = &project.description {
456            info.push_str(&format!("Description: {}\n", desc));
457        }
458        info.push_str(&format!("Version: {}\n", project.version));
459        info.push_str(&format!("Tags: {}\n", project.tags.join(", ")));
460
461        if !project.metadata.is_empty() {
462            info.push_str("\nMetadata:\n");
463            for (key, value) in &project.metadata {
464                info.push_str(&format!("  {}: {}\n", key, value));
465            }
466        }
467
468        Ok(info)
469    }
470
471    /// Simple project identification from current directory
472    pub fn identify_current_project(&self) -> Result<String> {
473        let project_file = self.workspace_root.join(".vtcode-project");
474        if project_file.exists() {
475            let content = fs::read_to_string(&project_file)?;
476            return Ok(content.trim().to_string());
477        }
478
479        self.workspace_root
480            .file_name()
481            .and_then(|name| name.to_str())
482            .map(|name| name.to_string())
483            .ok_or_else(|| anyhow::anyhow!("Could not determine project name from directory"))
484    }
485
486    /// Set current project
487    pub fn set_current_project(&self, name: &str) -> Result<()> {
488        let project_file = self.workspace_root.join(".vtcode-project");
489        fs::write(project_file, name)?;
490        Ok(())
491    }
492}
493
494/// Simple cache using file system
495#[cfg(feature = "cache")]
496pub struct SimpleCache {
497    cache_dir: PathBuf,
498}
499
500#[cfg(feature = "cache")]
501impl SimpleCache {
502    /// Create a new simple cache
503    pub fn new(cache_dir: PathBuf) -> Self {
504        Self { cache_dir }
505    }
506
507    /// Initialize cache directory
508    pub fn init(&self) -> Result<()> {
509        fs::create_dir_all(&self.cache_dir)?;
510        Ok(())
511    }
512
513    /// Store data in cache
514    pub fn store(&self, key: &str, data: &str) -> Result<()> {
515        let file_path = self.cache_dir.join(format!("{}.txt", key));
516        write_with_lock(&file_path, data.as_bytes())
517    }
518
519    /// Load data from cache
520    pub fn load(&self, key: &str) -> Result<String> {
521        let file_path = self.cache_dir.join(format!("{}.txt", key));
522        read_with_shared_lock(&file_path).map_err(|err| {
523            if err
524                .downcast_ref::<std::io::Error>()
525                .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::NotFound)
526            {
527                anyhow::anyhow!("Cache key '{}' not found", key)
528            } else {
529                err
530            }
531        })
532    }
533
534    /// Check if cache entry exists
535    pub fn exists(&self, key: &str) -> bool {
536        let file_path = self.cache_dir.join(format!("{}.txt", key));
537        file_path.exists()
538    }
539
540    /// Clear cache
541    pub fn clear(&self) -> Result<()> {
542        for entry in fs::read_dir(&self.cache_dir)? {
543            let entry = entry?;
544            if entry.path().is_file() {
545                fs::remove_file(entry.path())?;
546            }
547        }
548        Ok(())
549    }
550
551    /// List cache entries
552    pub fn list(&self) -> Result<Vec<String>> {
553        let mut entries = Vec::new();
554        for entry in fs::read_dir(&self.cache_dir)? {
555            let entry = entry?;
556            if let Some(name) = entry
557                .path()
558                .file_stem()
559                .and_then(|file_name| file_name.to_str())
560            {
561                entries.push(name.to_string());
562            }
563        }
564        Ok(entries)
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use serial_test::serial;
572    use std::sync::{Arc, Barrier};
573    use std::thread;
574    use tempfile::TempDir;
575
576    #[test]
577    fn markdown_storage_roundtrip() {
578        let dir = TempDir::new().expect("temp dir");
579        let storage = MarkdownStorage::new(dir.path().to_path_buf());
580        storage.init().expect("init storage");
581
582        #[derive(Serialize, Deserialize, PartialEq, Debug)]
583        struct Sample {
584            name: String,
585            value: u32,
586        }
587
588        let data = Sample {
589            name: "example".to_string(),
590            value: 42,
591        };
592
593        storage
594            .store("sample", &data, "Sample Data")
595            .expect("store");
596        let loaded: Sample = storage.load("sample").expect("load");
597        assert_eq!(loaded, data);
598    }
599
600    #[test]
601    #[serial]
602    fn concurrent_writes_preserve_integrity() {
603        let dir = TempDir::new().expect("temp dir");
604        let storage = MarkdownStorage::new(dir.path().to_path_buf());
605        storage.init().expect("init storage");
606
607        #[derive(Serialize, Deserialize, PartialEq, Debug)]
608        struct Sample {
609            name: String,
610            value: u32,
611        }
612
613        let barrier = Arc::new(Barrier::new(3));
614        let shared = Arc::new(storage);
615        let key = "concurrent";
616
617        let mut handles = Vec::new();
618        for (name, value) in [("first", 1u32), ("second", 2u32)] {
619            let barrier = barrier.clone();
620            let storage = shared.clone();
621            let key = key.to_string();
622            handles.push(thread::spawn(move || {
623                let data = Sample {
624                    name: name.to_string(),
625                    value,
626                };
627
628                barrier.wait();
629                storage
630                    .store(&key, &data, "Concurrent Sample")
631                    .expect("store concurrently");
632            }));
633        }
634
635        // Release the worker threads at roughly the same time.
636        barrier.wait();
637
638        for handle in handles {
639            handle.join().expect("join thread");
640        }
641
642        let final_value: Sample = shared
643            .load(key)
644            .expect("load value after concurrent writes");
645
646        assert!(
647            (final_value.name == "first" && final_value.value == 1)
648                || (final_value.name == "second" && final_value.value == 2),
649            "final value should match one of the writers, got {:?}",
650            final_value
651        );
652    }
653}