1use 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#[derive(Clone)]
20pub struct MarkdownStorage {
21 storage_dir: PathBuf,
22}
23
24impl MarkdownStorage {
25 pub fn new(storage_dir: PathBuf) -> Self {
27 Self { storage_dir }
28 }
29
30 pub fn init(&self) -> Result<()> {
32 fs::create_dir_all(&self.storage_dir)?;
33 Ok(())
34 }
35
36 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 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 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 pub fn delete(&self, key: &str) -> Result<()> {
70 let file_path = self.storage_dir.join(format!("{}.md", key));
71 if file_path.exists() {
72 if let Ok(file) = OpenOptions::new().read(true).write(true).open(&file_path) {
75 let _ = file.lock_exclusive();
76 drop(file);
78 }
79
80 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 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#[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#[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#[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#[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 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 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 pub fn init(&self) -> Result<()> {
385 self.storage.init()
386 }
387
388 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 pub fn load_project(&self, name: &str) -> Result<ProjectData> {
399 self.storage.load_project(name)
400 }
401
402 pub fn list_projects(&self) -> Result<Vec<String>> {
404 self.storage.list_projects()
405 }
406
407 pub fn delete_project(&self, name: &str) -> Result<()> {
409 self.storage.delete_project(name)
410 }
411
412 pub fn update_project(&self, project: &ProjectData) -> Result<()> {
414 self.storage.save_project(project)
415 }
416
417 pub fn project_data_dir(&self, project_name: &str) -> PathBuf {
419 self.project_root.join(project_name)
420 }
421
422 pub fn config_dir(&self, project_name: &str) -> PathBuf {
424 self.project_data_dir(project_name).join("config")
425 }
426
427 pub fn cache_dir(&self, project_name: &str) -> PathBuf {
429 self.project_data_dir(project_name).join("cache")
430 }
431
432 pub fn workspace_root(&self) -> &Path {
434 &self.workspace_root
435 }
436
437 pub fn project_root(&self) -> &Path {
439 &self.project_root
440 }
441
442 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 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 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 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#[cfg(feature = "cache")]
496pub struct SimpleCache {
497 cache_dir: PathBuf,
498}
499
500#[cfg(feature = "cache")]
501impl SimpleCache {
502 pub fn new(cache_dir: PathBuf) -> Self {
504 Self { cache_dir }
505 }
506
507 pub fn init(&self) -> Result<()> {
509 fs::create_dir_all(&self.cache_dir)?;
510 Ok(())
511 }
512
513 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 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 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 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 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 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}