1#[allow(dead_code)]
7#[derive(Debug, Clone)]
8pub struct WorkspaceConfig {
9 pub name: String,
10 pub version: String,
11 pub author: String,
12 pub output_dir: String,
13}
14
15impl WorkspaceConfig {
16 pub fn new(name: &str) -> Self {
17 Self {
18 name: name.to_string(),
19 version: "0.1.0".to_string(),
20 author: String::new(),
21 output_dir: "output".to_string(),
22 }
23 }
24
25 pub fn to_json(&self) -> String {
26 format!(
27 r#"{{"name":"{}","version":"{}","author":"{}","output_dir":"{}"}}"#,
28 self.name, self.version, self.author, self.output_dir
29 )
30 }
31
32 pub fn from_json(s: &str) -> Result<Self, String> {
34 let name = extract_json_str(s, "name").unwrap_or_default();
35 let version = extract_json_str(s, "version").unwrap_or_else(|| "0.1.0".to_string());
36 let author = extract_json_str(s, "author").unwrap_or_default();
37 let output_dir = extract_json_str(s, "output_dir").unwrap_or_else(|| "output".to_string());
38 if name.is_empty() {
39 return Err("missing name field".to_string());
40 }
41 Ok(Self {
42 name,
43 version,
44 author,
45 output_dir,
46 })
47 }
48}
49
50#[allow(dead_code)]
51#[derive(Debug, Clone)]
52pub struct AssetEntry {
53 pub path: String,
54 pub kind: String,
55 pub hash: String,
56 pub size_bytes: usize,
57}
58
59impl AssetEntry {
60 pub fn new(path: &str, kind: &str, hash: &str, size_bytes: usize) -> Self {
61 Self {
62 path: path.to_string(),
63 kind: kind.to_string(),
64 hash: hash.to_string(),
65 size_bytes,
66 }
67 }
68
69 pub fn to_json(&self) -> String {
70 format!(
71 r#"{{"path":"{}","kind":"{}","hash":"{}","size_bytes":{}}}"#,
72 self.path, self.kind, self.hash, self.size_bytes
73 )
74 }
75}
76
77#[allow(dead_code)]
78#[derive(Debug, Clone)]
79pub struct Workspace {
80 pub config: WorkspaceConfig,
81 pub assets: Vec<AssetEntry>,
82 pub dirty: bool,
83}
84
85impl Workspace {
86 pub fn new(name: &str) -> Self {
87 Self {
88 config: WorkspaceConfig::new(name),
89 assets: Vec::new(),
90 dirty: false,
91 }
92 }
93
94 pub fn add_asset(&mut self, path: &str, kind: &str, hash: &str, size_bytes: usize) {
96 if let Some(e) = self.assets.iter_mut().find(|a| a.path == path) {
98 e.kind = kind.to_string();
99 e.hash = hash.to_string();
100 e.size_bytes = size_bytes;
101 } else {
102 self.assets
103 .push(AssetEntry::new(path, kind, hash, size_bytes));
104 }
105 self.dirty = true;
106 }
107
108 pub fn remove_asset(&mut self, path: &str) -> bool {
110 let before = self.assets.len();
111 self.assets.retain(|a| a.path != path);
112 let removed = self.assets.len() < before;
113 if removed {
114 self.dirty = true;
115 }
116 removed
117 }
118
119 pub fn find_asset(&self, path: &str) -> Option<&AssetEntry> {
121 self.assets.iter().find(|a| a.path == path)
122 }
123
124 pub fn assets_by_kind(&self, kind: &str) -> Vec<&AssetEntry> {
126 self.assets.iter().filter(|a| a.kind == kind).collect()
127 }
128
129 pub fn total_size(&self) -> usize {
131 self.assets.iter().map(|a| a.size_bytes).sum()
132 }
133
134 pub fn asset_count(&self) -> usize {
136 self.assets.len()
137 }
138
139 pub fn to_json(&self) -> String {
141 let asset_jsons: Vec<String> = self.assets.iter().map(|a| a.to_json()).collect();
142 format!(
143 r#"{{"config":{},"dirty":{},"assets":[{}]}}"#,
144 self.config.to_json(),
145 self.dirty,
146 asset_jsons.join(",")
147 )
148 }
149
150 pub fn from_json(s: &str) -> Result<Workspace, String> {
152 let config_str =
154 extract_json_object(s, "config").ok_or_else(|| "missing config object".to_string())?;
155 let config = WorkspaceConfig::from_json(&config_str)?;
156 let assets = parse_asset_array(s);
158 let dirty_str = extract_json_str(s, "dirty").unwrap_or_else(|| "false".to_string());
159 let dirty = dirty_str == "true";
160 Ok(Workspace {
161 config,
162 assets,
163 dirty,
164 })
165 }
166
167 pub fn mark_clean(&mut self) {
169 self.dirty = false;
170 }
171}
172
173pub fn default_workspace_config() -> WorkspaceConfig {
175 WorkspaceConfig {
176 name: "default_project".to_string(),
177 version: "0.1.0".to_string(),
178 author: "Unknown".to_string(),
179 output_dir: "dist".to_string(),
180 }
181}
182
183pub fn workspace_summary(ws: &Workspace) -> String {
185 format!(
186 "Workspace '{}' v{} — {} assets, {} bytes total, dirty={}",
187 ws.config.name,
188 ws.config.version,
189 ws.asset_count(),
190 ws.total_size(),
191 ws.dirty
192 )
193}
194
195fn extract_json_str(s: &str, key: &str) -> Option<String> {
199 let pattern = format!("\"{}\":", key);
200 let start = s.find(&pattern)? + pattern.len();
201 let rest = s[start..].trim_start();
202 if rest.starts_with('"') {
203 let inner = rest.strip_prefix('"')?;
205 let end = inner.find('"')?;
206 Some(inner[..end].to_string())
207 } else {
208 let end = rest.find([',', '}', ']']).unwrap_or(rest.len());
210 Some(rest[..end].trim().to_string())
211 }
212}
213
214fn extract_json_object(s: &str, key: &str) -> Option<String> {
216 let pattern = format!("\"{}\":", key);
217 let start = s.find(&pattern)? + pattern.len();
218 let rest = s[start..].trim_start();
219 if !rest.starts_with('{') {
220 return None;
221 }
222 let mut depth = 0usize;
223 let mut end = 0;
224 for (i, c) in rest.char_indices() {
225 match c {
226 '{' => depth += 1,
227 '}' => {
228 depth -= 1;
229 if depth == 0 {
230 end = i + 1;
231 break;
232 }
233 }
234 _ => {}
235 }
236 }
237 Some(rest[..end].to_string())
238}
239
240fn parse_asset_array(s: &str) -> Vec<AssetEntry> {
242 let mut assets = Vec::new();
243 let marker = "\"assets\":[";
244 let Some(start) = s.find(marker) else {
245 return assets;
246 };
247 let rest = &s[start + marker.len()..];
248 let mut depth = 0i32;
250 let mut obj_start = None;
251 for (i, c) in rest.char_indices() {
252 match c {
253 '{' => {
254 if depth == 0 {
255 obj_start = Some(i);
256 }
257 depth += 1;
258 }
259 '}' => {
260 depth -= 1;
261 if depth == 0 {
262 if let Some(s_start) = obj_start {
263 let obj_str = &rest[s_start..=i];
264 let path = extract_json_str(obj_str, "path").unwrap_or_default();
265 let kind = extract_json_str(obj_str, "kind").unwrap_or_default();
266 let hash = extract_json_str(obj_str, "hash").unwrap_or_default();
267 let size: usize = extract_json_str(obj_str, "size_bytes")
268 .and_then(|v| v.parse().ok())
269 .unwrap_or(0);
270 if !path.is_empty() {
271 assets.push(AssetEntry::new(&path, &kind, &hash, size));
272 }
273 obj_start = None;
274 }
275 }
276 }
277 ']' if depth == 0 => break,
278 _ => {}
279 }
280 }
281 assets
282}
283
284#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn new_workspace_is_clean() {
291 let ws = Workspace::new("test");
292 assert!(!ws.dirty);
293 }
294
295 #[test]
296 fn new_workspace_name_stored() {
297 let ws = Workspace::new("myproject");
298 assert_eq!(ws.config.name, "myproject");
299 }
300
301 #[test]
302 fn add_asset_increases_count() {
303 let mut ws = Workspace::new("proj");
304 ws.add_asset("mesh.glb", "mesh", "abc123", 1024);
305 assert_eq!(ws.asset_count(), 1);
306 }
307
308 #[test]
309 fn add_asset_sets_dirty() {
310 let mut ws = Workspace::new("proj");
311 ws.add_asset("mesh.glb", "mesh", "abc123", 1024);
312 assert!(ws.dirty);
313 }
314
315 #[test]
316 fn remove_asset_returns_true_when_found() {
317 let mut ws = Workspace::new("proj");
318 ws.add_asset("mesh.glb", "mesh", "abc123", 1024);
319 assert!(ws.remove_asset("mesh.glb"));
320 assert_eq!(ws.asset_count(), 0);
321 }
322
323 #[test]
324 fn remove_asset_returns_false_when_missing() {
325 let mut ws = Workspace::new("proj");
326 assert!(!ws.remove_asset("nonexistent.glb"));
327 }
328
329 #[test]
330 fn find_asset_returns_correct_entry() {
331 let mut ws = Workspace::new("proj");
332 ws.add_asset("tex.png", "texture", "deadbeef", 512);
333 let found = ws.find_asset("tex.png");
334 assert!(found.is_some());
335 assert_eq!(found.expect("should succeed").kind, "texture");
336 }
337
338 #[test]
339 fn find_asset_returns_none_for_missing() {
340 let ws = Workspace::new("proj");
341 assert!(ws.find_asset("ghost.png").is_none());
342 }
343
344 #[test]
345 fn assets_by_kind_filters_correctly() {
346 let mut ws = Workspace::new("proj");
347 ws.add_asset("a.glb", "mesh", "h1", 100);
348 ws.add_asset("b.glb", "mesh", "h2", 200);
349 ws.add_asset("c.png", "texture", "h3", 50);
350 let meshes = ws.assets_by_kind("mesh");
351 assert_eq!(meshes.len(), 2);
352 }
353
354 #[test]
355 fn total_size_sums_correctly() {
356 let mut ws = Workspace::new("proj");
357 ws.add_asset("a", "mesh", "h1", 100);
358 ws.add_asset("b", "mesh", "h2", 200);
359 assert_eq!(ws.total_size(), 300);
360 }
361
362 #[test]
363 fn mark_clean_resets_dirty() {
364 let mut ws = Workspace::new("proj");
365 ws.add_asset("a", "mesh", "h1", 1);
366 assert!(ws.dirty);
367 ws.mark_clean();
368 assert!(!ws.dirty);
369 }
370
371 #[test]
372 fn to_json_round_trip() {
373 let mut ws = Workspace::new("roundtrip");
374 ws.add_asset("mesh.glb", "mesh", "cafebabe", 4096);
375 let json = ws.to_json();
376 let ws2 = Workspace::from_json(&json).expect("round-trip parse failed");
377 assert_eq!(ws2.config.name, "roundtrip");
378 assert_eq!(ws2.asset_count(), 1);
379 assert_eq!(
380 ws2.find_asset("mesh.glb")
381 .expect("should succeed")
382 .size_bytes,
383 4096
384 );
385 }
386
387 #[test]
388 fn from_json_error_on_missing_name() {
389 let result = Workspace::from_json(r#"{"config":{"version":"0.1.0"},"assets":[]}"#);
390 assert!(result.is_err());
391 }
392
393 #[test]
394 fn workspace_summary_contains_name() {
395 let ws = Workspace::new("showcase");
396 let s = workspace_summary(&ws);
397 assert!(s.contains("showcase"));
398 }
399
400 #[test]
401 fn default_workspace_config_has_name() {
402 let cfg = default_workspace_config();
403 assert!(!cfg.name.is_empty());
404 }
405}