Skip to main content

oxihuman_core/
workspace.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Project workspace management.
5
6#[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    /// Parse a minimal JSON config (best-effort).
33    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    /// Add or update an asset. Sets dirty = true.
95    pub fn add_asset(&mut self, path: &str, kind: &str, hash: &str, size_bytes: usize) {
96        // replace if path already exists
97        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    /// Remove an asset. Returns true if it was found and removed.
109    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    /// Find an asset by path.
120    pub fn find_asset(&self, path: &str) -> Option<&AssetEntry> {
121        self.assets.iter().find(|a| a.path == path)
122    }
123
124    /// All assets of a given kind.
125    pub fn assets_by_kind(&self, kind: &str) -> Vec<&AssetEntry> {
126        self.assets.iter().filter(|a| a.kind == kind).collect()
127    }
128
129    /// Sum of all asset sizes.
130    pub fn total_size(&self) -> usize {
131        self.assets.iter().map(|a| a.size_bytes).sum()
132    }
133
134    /// Number of assets.
135    pub fn asset_count(&self) -> usize {
136        self.assets.len()
137    }
138
139    /// Serialize workspace to JSON.
140    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    /// Deserialize workspace from JSON (best-effort).
151    pub fn from_json(s: &str) -> Result<Workspace, String> {
152        // Extract the config sub-object
153        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        // Parse assets array (simplified: each asset is an object {...})
157        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    /// Mark workspace as clean (dirty = false).
168    pub fn mark_clean(&mut self) {
169        self.dirty = false;
170    }
171}
172
173/// Default workspace configuration with sensible defaults.
174pub 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
183/// Human-readable summary of a workspace.
184pub 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
195// ─── Minimal JSON helpers ────────────────────────────────────────────────────
196
197/// Extract a string value for a key: "key":"value" or "key":value (booleans).
198fn 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        // quoted string
204        let inner = rest.strip_prefix('"')?;
205        let end = inner.find('"')?;
206        Some(inner[..end].to_string())
207    } else {
208        // unquoted token (number / boolean)
209        let end = rest.find([',', '}', ']']).unwrap_or(rest.len());
210        Some(rest[..end].trim().to_string())
211    }
212}
213
214/// Extract a JSON object value for a key (returns the raw {...} substring).
215fn 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
240/// Very small asset-array parser: finds each `{...}` inside `"assets":[...]`.
241fn 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    // collect all top-level { } objects
249    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// ─── Tests ───────────────────────────────────────────────────────────────────
285#[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}