bbx_draw/sketch/
sketchbook.rs

1//! Sketch discovery and management.
2
3use std::{fs, io, path::PathBuf, time::SystemTime};
4
5use serde::{Deserialize, Serialize};
6
7/// Metadata about a discovered sketch.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SketchMetadata {
10    /// The display name of the sketch.
11    pub name: String,
12    /// A brief description.
13    pub description: String,
14    /// Path to the sketch source file.
15    pub source_path: PathBuf,
16    /// Last modification time.
17    #[serde(with = "system_time_serde")]
18    pub last_modified: SystemTime,
19}
20
21/// Collection for discovering and caching sketch metadata.
22pub struct Sketchbook {
23    cache_dir: PathBuf,
24    entries: Vec<SketchMetadata>,
25}
26
27impl Sketchbook {
28    /// Create a new sketchbook.
29    ///
30    /// Uses the platform's cache directory (~/.cache/bbx_draw/sketches on Linux,
31    /// ~/Library/Caches/bbx_draw/sketches on macOS, etc.).
32    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    /// Create a sketchbook with a custom cache directory.
50    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    /// Get the cache directory path.
63    pub fn cache_dir(&self) -> &PathBuf {
64        &self.cache_dir
65    }
66
67    /// Discover sketches in a directory and update the sketchbook.
68    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    /// List all registered sketches.
93    pub fn list(&self) -> &[SketchMetadata] {
94        &self.entries
95    }
96
97    /// Get a sketch by name.
98    pub fn get(&self, name: &str) -> Option<&SketchMetadata> {
99        self.entries.iter().find(|e| e.name == name)
100    }
101
102    /// Remove a sketch from the sketchbook.
103    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    /// Add or update a sketch in the sketchbook.
114    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}