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, Write};
18use std::path::{Path, PathBuf};
19
20use anyhow::{Context, Result};
21use fs2::FileExt;
22use indexmap::IndexMap;
23use serde::{Deserialize, Serialize};
24
25#[derive(Clone)]
27pub struct MarkdownStorage {
28 storage_dir: PathBuf,
29}
30
31impl MarkdownStorage {
32 fn new(storage_dir: PathBuf) -> Self {
34 Self { storage_dir }
35 }
36
37 fn init(&self) -> Result<()> {
39 fs::create_dir_all(&self.storage_dir)?;
40 Ok(())
41 }
42
43 fn store<T: Serialize>(&self, key: &str, data: &T, title: &str) -> Result<()> {
45 let file_path = self.storage_dir.join(format!("{key}.md"));
46 let markdown = self.serialize_to_markdown(data, title)?;
47 write_with_lock(&file_path, markdown.as_bytes())
48 }
49
50 pub fn load<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Result<T> {
52 let file_path = self.storage_dir.join(format!("{key}.md"));
53 let content = read_with_shared_lock(&file_path)?;
54 self.deserialize_from_markdown(&content)
55 }
56
57 pub fn list(&self) -> Result<Vec<String>> {
59 let mut items = Vec::new();
60
61 for entry in fs::read_dir(&self.storage_dir)? {
62 let entry = entry?;
63 if let Some(name) = entry.path().file_stem().and_then(|file_name| file_name.to_str()) {
64 items.push(name.to_string());
65 }
66 }
67
68 Ok(items)
69 }
70
71 pub fn delete(&self, key: &str) -> Result<()> {
73 let file_path = self.storage_dir.join(format!("{key}.md"));
74 if file_path.exists() {
75 if let Ok(file) = OpenOptions::new().read(true).write(true).open(&file_path) {
78 let _ = file.lock_exclusive();
79 drop(file);
81 }
82
83 match fs::remove_file(&file_path) {
86 Ok(_) => {}
87 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
88 Err(err) => {
89 return Err(err)
90 .with_context(|| format!("Failed to delete markdown file at {}", file_path.display()));
91 }
92 }
93 }
94 Ok(())
95 }
96
97 pub fn exists(&self, key: &str) -> bool {
99 let file_path = self.storage_dir.join(format!("{key}.md"));
100 file_path.exists()
101 }
102
103 fn serialize_to_markdown<T: Serialize>(&self, data: &T, title: &str) -> Result<String> {
104 let json = serde_json::to_string_pretty(data)?;
105 let yaml = serde_saphyr::to_string(data)?;
106
107 let markdown = format!(
108 "# {}\n\n\
109 ## JSON\n\n\
110 ```json\n\
111 {}\n\
112 ```\n\n\
113 ## YAML\n\n\
114 ```yaml\n\
115 {}\n\
116 ```\n\n\
117 ## Raw Data\n\n\
118 {}\n",
119 title,
120 json,
121 yaml,
122 self.format_raw_data(data)
123 );
124
125 Ok(markdown)
126 }
127
128 fn deserialize_from_markdown<T: for<'de> Deserialize<'de>>(&self, content: &str) -> Result<T> {
129 if let Some(json_block) = self.extract_code_block(content, "json") {
130 return serde_json::from_str(json_block).context("Failed to parse JSON from markdown");
131 }
132
133 if let Some(yaml_block) = self.extract_code_block(content, "yaml") {
134 return serde_saphyr::from_str(yaml_block).context("Failed to parse YAML from markdown");
135 }
136
137 Err(anyhow::anyhow!("No valid JSON or YAML found in markdown"))
138 }
139
140 fn extract_code_block<'a>(&self, content: &'a str, language: &str) -> Option<&'a str> {
141 let start_pattern = format!("```{language}");
142 let end_pattern = "```";
143
144 if let Some(start_idx) = content.find(&start_pattern) {
145 let code_start = start_idx + start_pattern.len();
146 if let Some(end_idx) = content[code_start..].find(end_pattern) {
147 let code_end = code_start + end_idx;
148 return Some(content[code_start..code_end].trim());
149 }
150 }
151
152 None
153 }
154
155 fn format_raw_data<T: Serialize>(&self, data: &T) -> String {
156 match serde_json::to_value(data) {
157 Ok(serde_json::Value::Object(map)) => {
158 let mut lines = Vec::with_capacity(map.len());
159 for (key, value) in map {
160 lines.push(format!("- **{}**: {}", key, self.format_value(&value)));
161 }
162 lines.join("\n")
163 }
164 _ => "Complex data structure".to_string(),
165 }
166 }
167
168 fn format_value(&self, value: &serde_json::Value) -> String {
169 match value {
170 serde_json::Value::String(s) => format!("\"{s}\""),
171 serde_json::Value::Number(n) => n.to_string(),
172 serde_json::Value::Bool(b) => b.to_string(),
173 serde_json::Value::Array(arr) => format!("[{} items]", arr.len()),
174 serde_json::Value::Object(obj) => format!("{{{} fields}}", obj.len()),
175 serde_json::Value::Null => "null".to_string(),
176 }
177 }
178}
179
180fn write_with_lock(path: &Path, data: &[u8]) -> Result<()> {
181 if let Some(parent) = path.parent() {
182 fs::create_dir_all(parent)
183 .with_context(|| format!("Failed to ensure parent directory exists for {}", path.display()))?;
184 }
185
186 let mut file = OpenOptions::new()
187 .create(true)
188 .write(true)
189 .truncate(false)
190 .open(path)
191 .with_context(|| format!("Failed to open file at {}", path.display()))?;
192
193 FileExt::lock_exclusive(&file)
194 .with_context(|| format!("Failed to acquire exclusive lock for {}", path.display()))?;
195
196 file.set_len(0)
197 .with_context(|| format!("Failed to truncate file at {} while holding exclusive lock", path.display()))?;
198
199 file.write_all(data)
200 .with_context(|| format!("Failed to write file content to {} while holding exclusive lock", path.display()))?;
201
202 file.sync_all()
203 .with_context(|| format!("Failed to sync file at {} after writing with exclusive lock", path.display()))?;
204
205 FileExt::unlock(&file).with_context(|| format!("Failed to release exclusive lock for {}", path.display()))
206}
207
208fn read_with_shared_lock(path: &Path) -> Result<String> {
209 let mut file = OpenOptions::new()
210 .read(true)
211 .open(path)
212 .with_context(|| format!("Failed to open file at {}", path.display()))?;
213
214 FileExt::lock_shared(&file).with_context(|| format!("Failed to acquire shared lock for {}", path.display()))?;
215
216 let mut content = String::new();
217 file.read_to_string(&mut content)
218 .with_context(|| format!("Failed to read file content from {} while holding shared lock", path.display()))?;
219
220 FileExt::unlock(&file).with_context(|| format!("Failed to release shared lock for {}", path.display()))?;
221
222 Ok(content)
223}
224
225pub struct SimpleKVStorage {
227 storage: MarkdownStorage,
228}
229
230impl SimpleKVStorage {
231 pub fn new(storage_dir: PathBuf) -> Self {
232 Self { storage: MarkdownStorage::new(storage_dir) }
233 }
234
235 pub fn init(&self) -> Result<()> {
236 self.storage.init()
237 }
238
239 pub fn put(&self, key: &str, value: &str) -> Result<()> {
240 let data = IndexMap::from([("value".to_string(), value.to_string())]);
241 self.storage.store(key, &data, &format!("Key-Value: {key}"))
242 }
243
244 pub fn get(&self, key: &str) -> Result<String> {
245 let data: IndexMap<String, String> = self.storage.load(key)?;
246 data.get("value")
247 .cloned()
248 .ok_or_else(|| anyhow::anyhow!("Value not found for key: {key}"))
249 }
250
251 pub fn delete(&self, key: &str) -> Result<()> {
252 self.storage.delete(key)
253 }
254
255 pub fn list_keys(&self) -> Result<Vec<String>> {
256 self.storage.list()
257 }
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct ProjectData {
263 name: String,
264 pub description: Option<String>,
265 version: String,
266 tags: Vec<String>,
267 metadata: IndexMap<String, String>,
268}
269
270impl ProjectData {
271 pub fn new(name: &str) -> Self {
272 Self {
273 name: name.to_string(),
274 description: None,
275 version: "1.0.0".to_string(),
276 tags: vec![],
277 metadata: IndexMap::new(),
278 }
279 }
280}
281
282#[derive(Clone)]
284pub struct ProjectStorage {
285 storage: MarkdownStorage,
286}
287
288impl ProjectStorage {
289 fn new(storage_dir: PathBuf) -> Self {
290 Self { storage: MarkdownStorage::new(storage_dir) }
291 }
292
293 fn init(&self) -> Result<()> {
294 self.storage.init()
295 }
296
297 fn save_project(&self, project: &ProjectData) -> Result<()> {
298 self.storage
299 .store(&project.name, project, &format!("Project: {}", project.name))
300 }
301
302 pub fn load_project(&self, name: &str) -> Result<ProjectData> {
303 self.storage.load(name)
304 }
305
306 pub fn list_projects(&self) -> Result<Vec<String>> {
307 self.storage.list()
308 }
309
310 pub fn delete_project(&self, name: &str) -> Result<()> {
311 self.storage.delete(name)
312 }
313
314 pub fn storage_dir(&self) -> &Path {
315 &self.storage.storage_dir
316 }
317}
318
319#[derive(Clone)]
321pub struct SimpleProjectManager {
322 storage: ProjectStorage,
323 workspace_root: PathBuf,
324 project_root: PathBuf,
325}
326
327impl SimpleProjectManager {
328 pub fn new(workspace_root: PathBuf) -> Self {
331 let project_root = workspace_root.join(".vtcode").join("projects");
332 Self::with_project_root(workspace_root, project_root)
333 }
334
335 fn with_project_root(workspace_root: PathBuf, project_root: PathBuf) -> Self {
337 let storage = ProjectStorage::new(project_root.clone());
338 Self { storage, workspace_root, project_root }
339 }
340
341 pub fn init(&self) -> Result<()> {
343 self.storage.init()
344 }
345
346 pub fn create_project(&self, name: &str, description: Option<&str>) -> Result<()> {
348 let mut project = ProjectData::new(name);
349 project.description = description.map(|s| s.to_string());
350
351 self.storage.save_project(&project)?;
352 Ok(())
353 }
354
355 pub fn load_project(&self, name: &str) -> Result<ProjectData> {
357 self.storage.load_project(name)
358 }
359
360 pub fn list_projects(&self) -> Result<Vec<String>> {
362 self.storage.list_projects()
363 }
364
365 pub fn delete_project(&self, name: &str) -> Result<()> {
367 self.storage.delete_project(name)
368 }
369
370 pub fn update_project(&self, project: &ProjectData) -> Result<()> {
372 self.storage.save_project(project)
373 }
374
375 pub fn project_data_dir(&self, project_name: &str) -> PathBuf {
377 self.project_root.join(project_name)
378 }
379
380 pub fn config_dir(&self, project_name: &str) -> PathBuf {
382 self.project_data_dir(project_name).join("config")
383 }
384
385 pub fn cache_dir(&self, project_name: &str) -> PathBuf {
387 self.project_data_dir(project_name).join("cache")
388 }
389
390 pub fn workspace_root(&self) -> &Path {
392 &self.workspace_root
393 }
394
395 pub fn project_root(&self) -> &Path {
397 &self.project_root
398 }
399
400 pub fn project_exists(&self, name: &str) -> bool {
402 self.storage
403 .list_projects()
404 .map(|projects| projects.contains(&name.to_string()))
405 .unwrap_or(false)
406 }
407
408 pub fn get_project_info(&self, name: &str) -> Result<String> {
410 let project = self.load_project(name)?;
411
412 let mut info = format!("Project: {}\n", project.name);
413 if let Some(desc) = &project.description {
414 info.push_str(&format!("Description: {desc}\n"));
415 }
416 info.push_str(&format!("Version: {}\n", project.version));
417 info.push_str(&format!("Tags: {}\n", project.tags.join(", ")));
418
419 if !project.metadata.is_empty() {
420 info.push_str("\nMetadata:\n");
421 for (key, value) in &project.metadata {
422 info.push_str(&format!(" {key}: {value}\n"));
423 }
424 }
425
426 Ok(info)
427 }
428
429 pub fn identify_current_project(&self) -> Result<String> {
431 let project_file = self.workspace_root.join(".vtcode-project");
432 if project_file.exists() {
433 let content = fs::read_to_string(&project_file)?;
434 return Ok(content.trim().to_string());
435 }
436
437 self.workspace_root
438 .file_name()
439 .and_then(|name| name.to_str())
440 .map(|name| name.to_string())
441 .ok_or_else(|| anyhow::anyhow!("Could not determine project name from directory"))
442 }
443
444 pub fn set_current_project(&self, name: &str) -> Result<()> {
446 let project_file = self.workspace_root.join(".vtcode-project");
447 fs::write(project_file, name)?;
448 Ok(())
449 }
450}
451
452pub struct SimpleCache {
454 cache_dir: PathBuf,
455}
456
457impl SimpleCache {
458 pub fn new(cache_dir: PathBuf) -> Self {
460 Self { cache_dir }
461 }
462
463 pub fn init(&self) -> Result<()> {
465 fs::create_dir_all(&self.cache_dir)?;
466 Ok(())
467 }
468
469 pub fn store(&self, key: &str, data: &str) -> Result<()> {
471 let file_path = self.cache_dir.join(format!("{key}.txt"));
472 write_with_lock(&file_path, data.as_bytes())
473 }
474
475 pub fn load(&self, key: &str) -> Result<String> {
477 let file_path = self.cache_dir.join(format!("{key}.txt"));
478 read_with_shared_lock(&file_path).map_err(|err| {
479 if err
480 .downcast_ref::<std::io::Error>()
481 .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::NotFound)
482 {
483 anyhow::anyhow!("Cache key '{key}' not found")
484 } else {
485 err
486 }
487 })
488 }
489
490 pub fn exists(&self, key: &str) -> bool {
492 let file_path = self.cache_dir.join(format!("{key}.txt"));
493 file_path.exists()
494 }
495
496 pub fn clear(&self) -> Result<()> {
498 for entry in fs::read_dir(&self.cache_dir)? {
499 let entry = entry?;
500 if entry.path().is_file() {
501 fs::remove_file(entry.path())?;
502 }
503 }
504 Ok(())
505 }
506
507 pub fn list(&self) -> Result<Vec<String>> {
509 let mut entries = Vec::new();
510 for entry in fs::read_dir(&self.cache_dir)? {
511 let entry = entry?;
512 if let Some(name) = entry.path().file_stem().and_then(|file_name| file_name.to_str()) {
513 entries.push(name.to_string());
514 }
515 }
516 Ok(entries)
517 }
518}