vtcode_markdown_store/
lib.rs

1//! Markdown-backed storage utilities extracted from VTCode.
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_yaml::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_yaml::from_str(yaml_block).context("Failed to parse YAML from markdown");
133        }
134
135        Err(anyhow::anyhow!("No valid JSON or YAML found in markdown"))
136    }
137
138    fn extract_code_block<'a>(&self, content: &'a str, language: &str) -> Option<&'a str> {
139        let start_pattern = format!("```{}", language);
140        let end_pattern = "```";
141
142        if let Some(start_idx) = content.find(&start_pattern) {
143            let code_start = start_idx + start_pattern.len();
144            if let Some(end_idx) = content[code_start..].find(end_pattern) {
145                let code_end = code_start + end_idx;
146                return Some(content[code_start..code_end].trim());
147            }
148        }
149
150        None
151    }
152
153    fn format_raw_data<T: Serialize>(&self, data: &T) -> String {
154        match serde_json::to_value(data) {
155            Ok(serde_json::Value::Object(map)) => {
156                let mut lines = Vec::new();
157                for (key, value) in map {
158                    lines.push(format!("- **{}**: {}", key, self.format_value(&value)));
159                }
160                lines.join("\n")
161            }
162            _ => "Complex data structure".to_string(),
163        }
164    }
165
166    fn format_value(&self, value: &serde_json::Value) -> String {
167        match value {
168            serde_json::Value::String(s) => format!("\"{}\"", s),
169            serde_json::Value::Number(n) => n.to_string(),
170            serde_json::Value::Bool(b) => b.to_string(),
171            serde_json::Value::Array(arr) => format!("[{} items]", arr.len()),
172            serde_json::Value::Object(obj) => format!("{{{} fields}}", obj.len()),
173            serde_json::Value::Null => "null".to_string(),
174        }
175    }
176}
177
178fn write_with_lock(path: &Path, data: &[u8]) -> Result<()> {
179    if let Some(parent) = path.parent() {
180        fs::create_dir_all(parent).with_context(|| {
181            format!(
182                "Failed to ensure parent directory exists for {}",
183                path.display()
184            )
185        })?;
186    }
187
188    let mut file = OpenOptions::new()
189        .create(true)
190        .write(true)
191        .truncate(false)
192        .open(path)
193        .with_context(|| format!("Failed to open file at {}", path.display()))?;
194
195    file.lock_exclusive()
196        .with_context(|| format!("Failed to acquire exclusive lock for {}", path.display()))?;
197
198    file.set_len(0).with_context(|| {
199        format!(
200            "Failed to truncate file at {} while holding exclusive lock",
201            path.display()
202        )
203    })?;
204
205    file.write_all(data).with_context(|| {
206        format!(
207            "Failed to write file content to {} while holding exclusive lock",
208            path.display()
209        )
210    })?;
211
212    file.sync_all().with_context(|| {
213        format!(
214            "Failed to sync file at {} after writing with exclusive lock",
215            path.display()
216        )
217    })?;
218
219    file.unlock()
220        .with_context(|| format!("Failed to release exclusive lock for {}", path.display()))
221}
222
223fn read_with_shared_lock(path: &Path) -> Result<String> {
224    let mut file = OpenOptions::new()
225        .read(true)
226        .open(path)
227        .with_context(|| format!("Failed to open file at {}", path.display()))?;
228
229    file.lock_shared()
230        .with_context(|| format!("Failed to acquire shared lock for {}", path.display()))?;
231
232    let mut content = String::new();
233    file.read_to_string(&mut content).with_context(|| {
234        format!(
235            "Failed to read file content from {} while holding shared lock",
236            path.display()
237        )
238    })?;
239
240    file.unlock()
241        .with_context(|| format!("Failed to release shared lock for {}", path.display()))?;
242
243    Ok(content)
244}
245
246/// Simple key-value storage using markdown
247#[cfg(feature = "kv")]
248pub struct SimpleKVStorage {
249    storage: MarkdownStorage,
250}
251
252#[cfg(feature = "kv")]
253impl SimpleKVStorage {
254    pub fn new(storage_dir: PathBuf) -> Self {
255        Self {
256            storage: MarkdownStorage::new(storage_dir),
257        }
258    }
259
260    pub fn init(&self) -> Result<()> {
261        self.storage.init()
262    }
263
264    pub fn put(&self, key: &str, value: &str) -> Result<()> {
265        let data = IndexMap::from([("value".to_string(), value.to_string())]);
266        self.storage
267            .store(key, &data, &format!("Key-Value: {}", key))
268    }
269
270    pub fn get(&self, key: &str) -> Result<String> {
271        let data: IndexMap<String, String> = self.storage.load(key)?;
272        data.get("value")
273            .cloned()
274            .ok_or_else(|| anyhow::anyhow!("Value not found for key: {}", key))
275    }
276
277    pub fn delete(&self, key: &str) -> Result<()> {
278        self.storage.delete(key)
279    }
280
281    pub fn list_keys(&self) -> Result<Vec<String>> {
282        self.storage.list()
283    }
284}
285
286/// Simple project metadata storage
287#[cfg(feature = "projects")]
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct ProjectData {
290    pub name: String,
291    pub description: Option<String>,
292    pub version: String,
293    pub tags: Vec<String>,
294    pub metadata: IndexMap<String, String>,
295}
296
297#[cfg(feature = "projects")]
298impl ProjectData {
299    pub fn new(name: &str) -> Self {
300        Self {
301            name: name.to_string(),
302            description: None,
303            version: "1.0.0".to_string(),
304            tags: vec![],
305            metadata: IndexMap::new(),
306        }
307    }
308}
309
310/// Project storage using markdown
311#[cfg(feature = "projects")]
312#[derive(Clone)]
313pub struct ProjectStorage {
314    storage: MarkdownStorage,
315}
316
317#[cfg(feature = "projects")]
318impl ProjectStorage {
319    pub fn new(storage_dir: PathBuf) -> Self {
320        Self {
321            storage: MarkdownStorage::new(storage_dir),
322        }
323    }
324
325    pub fn init(&self) -> Result<()> {
326        self.storage.init()
327    }
328
329    pub fn save_project(&self, project: &ProjectData) -> Result<()> {
330        self.storage.store(
331            &project.name,
332            project,
333            &format!("Project: {}", project.name),
334        )
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#[cfg(feature = "projects")]
356#[derive(Clone)]
357pub struct SimpleProjectManager {
358    storage: ProjectStorage,
359    workspace_root: PathBuf,
360    project_root: PathBuf,
361}
362
363#[cfg(feature = "projects")]
364impl SimpleProjectManager {
365    /// Construct a project manager that stores metadata under
366    /// `<workspace_root>/.vtcode/projects`.
367    pub fn new(workspace_root: PathBuf) -> Self {
368        let project_root = workspace_root.join(".vtcode").join("projects");
369        Self::with_project_root(workspace_root, project_root)
370    }
371
372    /// Construct a manager with a caller-supplied project storage root.
373    pub fn with_project_root(workspace_root: PathBuf, project_root: PathBuf) -> Self {
374        let storage = ProjectStorage::new(project_root.clone());
375        Self {
376            storage,
377            workspace_root,
378            project_root,
379        }
380    }
381
382    /// Initialize the project manager
383    pub fn init(&self) -> Result<()> {
384        self.storage.init()
385    }
386
387    /// Create a new project
388    pub fn create_project(&self, name: &str, description: Option<&str>) -> Result<()> {
389        let mut project = ProjectData::new(name);
390        project.description = description.map(|s| s.to_string());
391
392        self.storage.save_project(&project)?;
393        Ok(())
394    }
395
396    /// Load a project by name
397    pub fn load_project(&self, name: &str) -> Result<ProjectData> {
398        self.storage.load_project(name)
399    }
400
401    /// List all projects
402    pub fn list_projects(&self) -> Result<Vec<String>> {
403        self.storage.list_projects()
404    }
405
406    /// Delete a project
407    pub fn delete_project(&self, name: &str) -> Result<()> {
408        self.storage.delete_project(name)
409    }
410
411    /// Update project metadata
412    pub fn update_project(&self, project: &ProjectData) -> Result<()> {
413        self.storage.save_project(project)
414    }
415
416    /// Get project data directory
417    pub fn project_data_dir(&self, project_name: &str) -> PathBuf {
418        self.project_root.join(project_name)
419    }
420
421    /// Get project config directory
422    pub fn config_dir(&self, project_name: &str) -> PathBuf {
423        self.project_data_dir(project_name).join("config")
424    }
425
426    /// Get project cache directory
427    pub fn cache_dir(&self, project_name: &str) -> PathBuf {
428        self.project_data_dir(project_name).join("cache")
429    }
430
431    /// Get workspace root
432    pub fn workspace_root(&self) -> &Path {
433        &self.workspace_root
434    }
435
436    /// Return the root directory backing project metadata.
437    pub fn project_root(&self) -> &Path {
438        &self.project_root
439    }
440
441    /// Check if project exists
442    pub fn project_exists(&self, name: &str) -> bool {
443        self.storage
444            .list_projects()
445            .map(|projects| projects.contains(&name.to_string()))
446            .unwrap_or(false)
447    }
448
449    /// Get project info as simple text
450    pub fn get_project_info(&self, name: &str) -> Result<String> {
451        let project = self.load_project(name)?;
452
453        let mut info = format!("Project: {}\n", project.name);
454        if let Some(desc) = &project.description {
455            info.push_str(&format!("Description: {}\n", desc));
456        }
457        info.push_str(&format!("Version: {}\n", project.version));
458        info.push_str(&format!("Tags: {}\n", project.tags.join(", ")));
459
460        if !project.metadata.is_empty() {
461            info.push_str("\nMetadata:\n");
462            for (key, value) in &project.metadata {
463                info.push_str(&format!("  {}: {}\n", key, value));
464            }
465        }
466
467        Ok(info)
468    }
469
470    /// Simple project identification from current directory
471    pub fn identify_current_project(&self) -> Result<String> {
472        let project_file = self.workspace_root.join(".vtcode-project");
473        if project_file.exists() {
474            let content = fs::read_to_string(&project_file)?;
475            return Ok(content.trim().to_string());
476        }
477
478        self.workspace_root
479            .file_name()
480            .and_then(|name| name.to_str())
481            .map(|name| name.to_string())
482            .ok_or_else(|| anyhow::anyhow!("Could not determine project name from directory"))
483    }
484
485    /// Set current project
486    pub fn set_current_project(&self, name: &str) -> Result<()> {
487        let project_file = self.workspace_root.join(".vtcode-project");
488        fs::write(project_file, name)?;
489        Ok(())
490    }
491}
492
493/// Simple cache using file system
494#[cfg(feature = "cache")]
495pub struct SimpleCache {
496    cache_dir: PathBuf,
497}
498
499#[cfg(feature = "cache")]
500impl SimpleCache {
501    /// Create a new simple cache
502    pub fn new(cache_dir: PathBuf) -> Self {
503        Self { cache_dir }
504    }
505
506    /// Initialize cache directory
507    pub fn init(&self) -> Result<()> {
508        fs::create_dir_all(&self.cache_dir)?;
509        Ok(())
510    }
511
512    /// Store data in cache
513    pub fn store(&self, key: &str, data: &str) -> Result<()> {
514        let file_path = self.cache_dir.join(format!("{}.txt", key));
515        write_with_lock(&file_path, data.as_bytes())
516    }
517
518    /// Load data from cache
519    pub fn load(&self, key: &str) -> Result<String> {
520        let file_path = self.cache_dir.join(format!("{}.txt", key));
521        read_with_shared_lock(&file_path).map_err(|err| {
522            if err
523                .downcast_ref::<std::io::Error>()
524                .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::NotFound)
525            {
526                anyhow::anyhow!("Cache key '{}' not found", key)
527            } else {
528                err
529            }
530        })
531    }
532
533    /// Check if cache entry exists
534    pub fn exists(&self, key: &str) -> bool {
535        let file_path = self.cache_dir.join(format!("{}.txt", key));
536        file_path.exists()
537    }
538
539    /// Clear cache
540    pub fn clear(&self) -> Result<()> {
541        for entry in fs::read_dir(&self.cache_dir)? {
542            let entry = entry?;
543            if entry.path().is_file() {
544                fs::remove_file(entry.path())?;
545            }
546        }
547        Ok(())
548    }
549
550    /// List cache entries
551    pub fn list(&self) -> Result<Vec<String>> {
552        let mut entries = Vec::new();
553        for entry in fs::read_dir(&self.cache_dir)? {
554            let entry = entry?;
555            if let Some(name) = entry
556                .path()
557                .file_stem()
558                .and_then(|file_name| file_name.to_str())
559            {
560                entries.push(name.to_string());
561            }
562        }
563        Ok(entries)
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use serial_test::serial;
571    use std::sync::{Arc, Barrier};
572    use std::thread;
573    use tempfile::TempDir;
574
575    #[test]
576    fn markdown_storage_roundtrip() {
577        let dir = TempDir::new().expect("temp dir");
578        let storage = MarkdownStorage::new(dir.path().to_path_buf());
579        storage.init().expect("init storage");
580
581        #[derive(Serialize, Deserialize, PartialEq, Debug)]
582        struct Sample {
583            name: String,
584            value: u32,
585        }
586
587        let data = Sample {
588            name: "example".to_string(),
589            value: 42,
590        };
591
592        storage
593            .store("sample", &data, "Sample Data")
594            .expect("store");
595        let loaded: Sample = storage.load("sample").expect("load");
596        assert_eq!(loaded, data);
597    }
598
599    #[test]
600    #[serial]
601    fn concurrent_writes_preserve_integrity() {
602        let dir = TempDir::new().expect("temp dir");
603        let storage = MarkdownStorage::new(dir.path().to_path_buf());
604        storage.init().expect("init storage");
605
606        #[derive(Serialize, Deserialize, PartialEq, Debug)]
607        struct Sample {
608            name: String,
609            value: u32,
610        }
611
612        let barrier = Arc::new(Barrier::new(3));
613        let shared = Arc::new(storage);
614        let key = "concurrent";
615
616        let mut handles = Vec::new();
617        for (name, value) in [("first", 1u32), ("second", 2u32)] {
618            let barrier = barrier.clone();
619            let storage = shared.clone();
620            let key = key.to_string();
621            handles.push(thread::spawn(move || {
622                let data = Sample {
623                    name: name.to_string(),
624                    value,
625                };
626
627                barrier.wait();
628                storage
629                    .store(&key, &data, "Concurrent Sample")
630                    .expect("store concurrently");
631            }));
632        }
633
634        // Release the worker threads at roughly the same time.
635        barrier.wait();
636
637        for handle in handles {
638            handle.join().expect("join thread");
639        }
640
641        let final_value: Sample = shared
642            .load(key)
643            .expect("load value after concurrent writes");
644
645        assert!(
646            (final_value.name == "first" && final_value.value == 1)
647                || (final_value.name == "second" && final_value.value == 2),
648            "final value should match one of the writers, got {:?}",
649            final_value
650        );
651    }
652}