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
8use 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#[derive(Clone)]
32pub struct MarkdownStorage {
33 storage_dir: PathBuf,
34}
35
36impl MarkdownStorage {
37 fn new(storage_dir: PathBuf) -> Self {
39 Self { storage_dir }
40 }
41
42 fn init(&self) -> Result<()> {
44 canonical_storage_dir(&self.storage_dir)?;
45 Ok(())
46 }
47
48 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 fn storage_path(&self, key: &str) -> Result<PathBuf> {
61 contained_record_path(&self.storage_dir, key, "md")
62 }
63
64 fn existing_storage_path(&self, key: &str) -> Result<PathBuf> {
66 contained_existing_record_path(&self.storage_dir, key, "md")
67 }
68
69 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 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 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 if let Ok(file) = OpenOptions::new().read(true).write(true).open(&file_path) {
102 let _ = file.lock_exclusive();
103 drop(file);
105 }
106
107 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 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 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
260pub 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#[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#[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#[derive(Clone)]
356pub struct SimpleProjectManager {
357 storage: ProjectStorage,
358 workspace_root: PathBuf,
359 project_root: PathBuf,
360}
361
362impl SimpleProjectManager {
363 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 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 pub fn init(&self) -> Result<()> {
378 self.storage.init()
379 }
380
381 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 pub fn load_project(&self, name: &str) -> Result<ProjectData> {
392 self.storage.load_project(name)
393 }
394
395 pub fn list_projects(&self) -> Result<Vec<String>> {
397 self.storage.list_projects()
398 }
399
400 pub fn delete_project(&self, name: &str) -> Result<()> {
402 self.storage.delete_project(name)
403 }
404
405 pub fn update_project(&self, project: &ProjectData) -> Result<()> {
407 self.storage.save_project(project)
408 }
409
410 pub fn project_data_dir(&self, project_name: &str) -> PathBuf {
412 self.project_root.join(project_name)
413 }
414
415 pub fn config_dir(&self, project_name: &str) -> PathBuf {
417 self.project_data_dir(project_name).join("config")
418 }
419
420 pub fn cache_dir(&self, project_name: &str) -> PathBuf {
422 self.project_data_dir(project_name).join("cache")
423 }
424
425 pub fn workspace_root(&self) -> &Path {
427 &self.workspace_root
428 }
429
430 pub fn project_root(&self) -> &Path {
432 &self.project_root
433 }
434
435 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 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 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 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
487pub struct SimpleCache {
489 cache_dir: PathBuf,
490}
491
492impl SimpleCache {
493 pub fn new(cache_dir: PathBuf) -> Self {
495 Self { cache_dir }
496 }
497
498 pub fn init(&self) -> Result<()> {
500 canonical_storage_dir(&self.cache_dir)?;
501 Ok(())
502 }
503
504 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 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 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 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 vtcode_commons::walk::entry_is_file(&entry) {
539 fs::remove_file(entry.path())?;
540 }
541 }
542 Ok(())
543 }
544
545 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}