bbx_draw/sketch/
sketchbook.rs1use std::{fs, io, path::PathBuf, time::SystemTime};
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SketchMetadata {
10 pub name: String,
12 pub description: String,
14 pub source_path: PathBuf,
16 #[serde(with = "system_time_serde")]
18 pub last_modified: SystemTime,
19}
20
21pub struct Sketchbook {
23 cache_dir: PathBuf,
24 entries: Vec<SketchMetadata>,
25}
26
27impl Sketchbook {
28 pub fn new() -> io::Result<Self> {
33 let cache_dir = dirs::cache_dir()
34 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No cache directory found"))?
35 .join("bbx_draw")
36 .join("sketches");
37
38 fs::create_dir_all(&cache_dir)?;
39
40 let mut registry = Self {
41 cache_dir,
42 entries: Vec::new(),
43 };
44
45 registry.load_cache()?;
46 Ok(registry)
47 }
48
49 pub fn with_cache_dir(cache_dir: PathBuf) -> io::Result<Self> {
51 fs::create_dir_all(&cache_dir)?;
52
53 let mut registry = Self {
54 cache_dir,
55 entries: Vec::new(),
56 };
57
58 registry.load_cache()?;
59 Ok(registry)
60 }
61
62 pub fn cache_dir(&self) -> &PathBuf {
64 &self.cache_dir
65 }
66
67 pub fn discover(&mut self, search_dir: &PathBuf) -> io::Result<usize> {
69 let mut discovered = 0;
70
71 if !search_dir.exists() {
72 return Ok(0);
73 }
74
75 for entry in fs::read_dir(search_dir)? {
76 let entry = entry?;
77 let path = entry.path();
78
79 if path.extension().is_some_and(|ext| ext == "rs")
80 && let Ok(metadata) = Self::extract_metadata(&path)
81 && !self.entries.iter().any(|e| e.source_path == metadata.source_path)
82 {
83 self.entries.push(metadata);
84 discovered += 1;
85 }
86 }
87
88 self.save_cache()?;
89 Ok(discovered)
90 }
91
92 pub fn list(&self) -> &[SketchMetadata] {
94 &self.entries
95 }
96
97 pub fn get(&self, name: &str) -> Option<&SketchMetadata> {
99 self.entries.iter().find(|e| e.name == name)
100 }
101
102 pub fn remove(&mut self, name: &str) -> Option<SketchMetadata> {
104 if let Some(pos) = self.entries.iter().position(|e| e.name == name) {
105 let removed = self.entries.remove(pos);
106 let _ = self.save_cache();
107 Some(removed)
108 } else {
109 None
110 }
111 }
112
113 pub fn register(&mut self, metadata: SketchMetadata) -> io::Result<()> {
115 if let Some(existing) = self.entries.iter_mut().find(|e| e.name == metadata.name) {
116 *existing = metadata;
117 } else {
118 self.entries.push(metadata);
119 }
120 self.save_cache()
121 }
122
123 fn cache_file_path(&self) -> PathBuf {
124 self.cache_dir.join("registry.json")
125 }
126
127 fn load_cache(&mut self) -> io::Result<()> {
128 let cache_file = self.cache_file_path();
129 if cache_file.exists() {
130 let content = fs::read_to_string(&cache_file)?;
131 self.entries = serde_json::from_str(&content).unwrap_or_default();
132 }
133 Ok(())
134 }
135
136 fn save_cache(&self) -> io::Result<()> {
137 let cache_file = self.cache_file_path();
138 let content = serde_json::to_string_pretty(&self.entries)?;
139 fs::write(cache_file, content)
140 }
141
142 fn extract_metadata(path: &PathBuf) -> io::Result<SketchMetadata> {
143 let content = fs::read_to_string(path)?;
144 let file_meta = fs::metadata(path)?;
145
146 let name = path
147 .file_stem()
148 .and_then(|s| s.to_str())
149 .unwrap_or("unknown")
150 .to_string();
151
152 let description = Self::extract_doc_comment(&content).unwrap_or_else(|| format!("Sketch: {name}"));
153
154 Ok(SketchMetadata {
155 name,
156 description,
157 source_path: path.clone(),
158 last_modified: file_meta.modified()?,
159 })
160 }
161
162 fn extract_doc_comment(content: &str) -> Option<String> {
163 let mut doc_lines = Vec::new();
164 for line in content.lines() {
165 let trimmed = line.trim();
166 if trimmed.starts_with("//!") {
167 doc_lines.push(trimmed.trim_start_matches("//!").trim());
168 } else if !trimmed.is_empty() && !trimmed.starts_with("//") {
169 break;
170 }
171 }
172
173 if doc_lines.is_empty() {
174 None
175 } else {
176 Some(doc_lines.join(" "))
177 }
178 }
179}
180
181mod system_time_serde {
182 use std::time::{Duration, SystemTime, UNIX_EPOCH};
183
184 use serde::{Deserialize, Deserializer, Serialize, Serializer};
185
186 pub fn serialize<S>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error>
187 where
188 S: Serializer,
189 {
190 let duration = time.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO);
191 duration.as_secs().serialize(serializer)
192 }
193
194 pub fn deserialize<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
195 where
196 D: Deserializer<'de>,
197 {
198 let secs = u64::deserialize(deserializer)?;
199 Ok(UNIX_EPOCH + Duration::from_secs(secs))
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn test_extract_doc_comment() {
209 let content = r#"//! This is a test sketch.
210//! It does something cool.
211
212use something;
213"#;
214 let desc = Sketchbook::extract_doc_comment(content);
215 assert_eq!(desc, Some("This is a test sketch. It does something cool.".to_string()));
216 }
217
218 #[test]
219 fn test_extract_no_doc_comment() {
220 let content = "use something;\nfn main() {}";
221 let desc = Sketchbook::extract_doc_comment(content);
222 assert_eq!(desc, None);
223 }
224}