Skip to main content

concinnity_dev/authoring/
add.rs

1// src/add.rs
2// Add an asset to a world JSONL and rebuild.
3//
4// The CLI and FFI both funnel through `add_to_path`, which:
5//   - bootstraps a missing world from a `.glb` / `.txt` / `.md` target (a
6//     text target becomes a `TextLabel` whose content is the file body); the
7//     renderer stack itself is injected at build time from the entries'
8//     companions, so no scaffold lines are written,
9//   - appends a named content template's entries (`--template minimal-3d-world`)
10//     when one is requested for a `.glb` landing in a renderer-less world,
11//   - resolves `target` as a file path, a known asset type name, or inline
12//     JSON, building one or more asset entries,
13//   - patches the world JSONL atomically (via a tmp file) and reruns the
14//     build pipeline so blobs and the lock file stay in sync. Only the
15//     requested entries are written; injected companions and engine defaults
16//     stay build-time only (see world-lock.json).
17
18use crate::world::{WORLD_JSONL, patch_world_jsonl_to};
19use concinnity_cook::asset_api::{AssetRequest, create_asset_def};
20use concinnity_cook::build_from_path;
21use concinnity_world::registry::RegisteredType;
22
23/// Add an asset to `world_path` and rebuild. See module docs.
24///
25/// `template` selects a named scaffold preset when scaffolding fires
26/// (target is `.glb`, world has no renderer trigger). `None` uses the
27/// default scaffold; `Some("minimal-3d-world")` layers that template's
28/// entries. Unknown names error out before touching the world file.
29pub fn add_to_path(
30    world_path: &str,
31    name: Option<&str>,
32    target: &str,
33    template: Option<&str>,
34) -> std::io::Result<()> {
35    let scaffold = scaffold_to_inject(world_path, target, template)?;
36    ensure_world_file_exists(world_path)?;
37
38    let mut entries = resolve_add_target(target)?;
39
40    if let Some(n) = name {
41        apply_name_override(&mut entries, n);
42    }
43
44    let entry_names: Vec<String> = entries
45        .iter()
46        .map(|e| {
47            e.get("name")
48                .and_then(|v| v.as_str())
49                .ok_or_else(|| {
50                    std::io::Error::new(
51                        std::io::ErrorKind::InvalidData,
52                        "resolved asset entry has no `name` field",
53                    )
54                })
55                .map(str::to_string)
56        })
57        .collect::<Result<_, _>>()?;
58
59    let tmp_path = format!("{}.tmp", world_path);
60
61    patch_world_jsonl_to(world_path, &tmp_path, |assets| {
62        // Re-adding a text file (or any other single-entry TextLabel target)
63        // refreshes the existing same-name TextLabel's `content` in place
64        // instead of erroring. Drag-dropping the same `.txt` twice (or
65        // editing its body and re-adding) should "just work". Other args
66        // (font, x/y, color, scale, centered, ...) are left alone so any
67        // hand edits to the TextLabel survive the refresh.
68        if let Some(refreshed) = try_refresh_text_label(assets, &entries) {
69            tracing::info!("refreshed TextLabel '{}' content", refreshed);
70            return Ok(());
71        }
72
73        // Likewise for an `.hdr`: a world binds one lighting environment, so a
74        // second EnvironmentMap would never render. Point the existing one at
75        // the new source instead of appending.
76        if let Some((retargeted, source)) = try_retarget_environment_map(assets, &entries) {
77            // Printed, not just traced: this edits an asset the user already
78            // authored, so it must not look like a plain append.
79            println!("Pointed existing EnvironmentMap '{retargeted}' at {source}");
80            return Ok(());
81        }
82
83        for entry_name in &entry_names {
84            if let Some(existing) = assets
85                .iter()
86                .find(|a| a.get("name").and_then(|v| v.as_str()) == Some(entry_name.as_str()))
87            {
88                let existing_type = existing.get("type").and_then(|v| v.as_str()).unwrap_or("?");
89                return Err(std::io::Error::new(
90                    std::io::ErrorKind::AlreadyExists,
91                    format!(
92                        "an asset named '{}' (type: {}) already exists in {}; \
93                         remove it first with `concinnity rm {}`",
94                        entry_name, existing_type, WORLD_JSONL, entry_name
95                    ),
96                ));
97            }
98        }
99        // Append template entries first so the new asset's own systems (e.g.
100        // a glTF's Camera3D) run alongside the template's setup. Any template
101        // entry whose name already exists in the world is skipped to avoid
102        // clobbering user-authored assets that happen to share the name.
103        for entry in scaffold {
104            let n = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
105            if !assets
106                .iter()
107                .any(|a| a.get("name").and_then(|v| v.as_str()) == Some(n))
108            {
109                assets.push(entry);
110            }
111        }
112        assets.extend(entries);
113        Ok(())
114    })?;
115
116    match build_from_path(&tmp_path) {
117        Ok(()) => std::fs::rename(&tmp_path, world_path).inspect_err(|_e| {
118            let _ = std::fs::remove_file(&tmp_path);
119        }),
120        Err(e) => {
121            let _ = std::fs::remove_file(&tmp_path);
122            Err(e)
123        }
124    }
125}
126
127// Apply a caller-supplied name to freshly resolved entries: a single entry is
128// renamed outright; a multi-entry target (e.g. a .metal file with both vertex
129// and fragment stages) uses the supplied name as a prefix, keeping each
130// entry's existing `_vert` / `_frag` style suffix.
131pub(crate) fn apply_name_override(entries: &mut [serde_json::Value], name: &str) {
132    if let [only] = entries {
133        only["name"] = serde_json::Value::String(name.to_string());
134        return;
135    }
136    for entry in entries {
137        let existing = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
138        let suffix = existing
139            .rsplit_once('_')
140            .map(|(_, s)| format!("_{s}"))
141            .unwrap_or_default();
142        entry["name"] = serde_json::Value::String(format!("{name}{suffix}"));
143    }
144}
145
146// If `entries` is a single TextLabel whose name matches an existing
147// TextLabel in `assets`, overwrite only the existing entry's `args.content`
148// from the new entry and return the name. Returns `None` otherwise: the
149// caller falls back to the normal "append, error on duplicate" flow.
150//
151// Scope is intentionally narrow:
152//   - Single-entry only, so a `.glb` re-add (which fans into many entries)
153//     doesn't accidentally clobber one of the existing materials/meshes.
154//   - Same name AND same type, so a TextLabel never overwrites an
155//     unrelated asset that happens to share a name.
156//   - Only `content` is copied across; the user's edits to font, x/y,
157//     color, scale, centered, background, padding, visible, view all stay.
158fn try_refresh_text_label(
159    assets: &mut [serde_json::Value],
160    entries: &[serde_json::Value],
161) -> Option<String> {
162    if entries.len() != 1 {
163        return None;
164    }
165    let new = &entries[0];
166    if new.get("type").and_then(|v| v.as_str()) != Some("TextLabel") {
167        return None;
168    }
169    let new_name = new.get("name").and_then(|v| v.as_str())?.to_string();
170    let new_content = new.get("args").and_then(|a| a.get("content")).cloned()?;
171
172    let existing = assets.iter_mut().find(|a| {
173        a.get("name").and_then(|v| v.as_str()) == Some(new_name.as_str())
174            && a.get("type").and_then(|v| v.as_str()) == Some("TextLabel")
175    })?;
176    let args = existing.get_mut("args")?.as_object_mut()?;
177    args.insert("content".to_string(), new_content);
178    Some(new_name)
179}
180
181// If `entries` is a single EnvironmentMap and `assets` already declares one,
182// point that existing entry at the new source and return its name plus the
183// source. Returns `None` otherwise: the caller falls back to the normal
184// "append, error on duplicate" flow.
185//
186// The runtime binds the first EnvironmentMap it finds and ignores the rest, so
187// appending a second would leave the just-added `.hdr` dark. Only `source` (and
188// the `generator` it displaces, since the two are mutually exclusive) is
189// written; the existing prefilter / irradiance / clamp tuning survives.
190pub(crate) fn try_retarget_environment_map(
191    assets: &mut [serde_json::Value],
192    entries: &[serde_json::Value],
193) -> Option<(String, String)> {
194    if entries.len() != 1 {
195        return None;
196    }
197    let new = &entries[0];
198    if new.get("type").and_then(|v| v.as_str()) != Some("EnvironmentMap") {
199        return None;
200    }
201    let new_source = new.get("args")?.get("source")?.as_str()?.to_string();
202
203    let existing = assets
204        .iter_mut()
205        .find(|a| a.get("type").and_then(|v| v.as_str()) == Some("EnvironmentMap"))?;
206    let name = existing.get("name").and_then(|v| v.as_str())?.to_string();
207    let args = existing.get_mut("args")?.as_object_mut()?;
208    args.insert(
209        "source".to_string(),
210        serde_json::Value::String(new_source.clone()),
211    );
212    args.insert(
213        "generator".to_string(),
214        serde_json::Value::String(String::new()),
215    );
216    Some((name, new_source))
217}
218
219// Decide which scaffold entries the patch closure should inject. Returns
220// empty when no scaffolding is needed: the target doesn't bootstrap a world,
221// the world already has a renderer-trigger asset (GraphicsSystem /
222// GraphicsConfig / TextLabel / Window), or the target type provides its own
223// renderer trigger (e.g. a text target's TextLabel). Errors when the world
224// file is missing and the target can't bootstrap one: only `.glb`, `.txt`,
225// and `.md` are allowed to create a world from nothing.
226//
227// `template` picks which scaffold flavour to inject. Unknown names error
228// out before any file I/O so the user sees the typo immediately. An unknown
229// template is treated as an error even when scaffolding wouldn't fire
230// (e.g. existing world with a renderer trigger), since silently ignoring
231// `--template foo` would mask the typo. `--template` is GLB-only: passing
232// it with a text target is also rejected so the typo doesn't survive.
233fn scaffold_to_inject(
234    world_path: &str,
235    target: &str,
236    template: Option<&str>,
237) -> std::io::Result<Vec<serde_json::Value>> {
238    let template_entries = resolve_template(template)?;
239
240    let world_exists = std::path::Path::new(world_path).exists();
241    let bootstrap = target_bootstrap_kind(target);
242
243    if !world_exists && matches!(bootstrap, BootstrapKind::None) {
244        return Err(std::io::Error::new(
245            std::io::ErrorKind::NotFound,
246            format!(
247                "no world found at '{}': create one with `cn fetch-world` or `cn new`",
248                world_path
249            ),
250        ));
251    }
252
253    if template_entries.is_some() && !matches!(bootstrap, BootstrapKind::Scene) {
254        return Err(std::io::Error::new(
255            std::io::ErrorKind::InvalidInput,
256            "--template only applies to 3D scene targets (.glb)",
257        ));
258    }
259
260    match bootstrap {
261        BootstrapKind::None | BootstrapKind::Text => Ok(Vec::new()),
262        BootstrapKind::Scene => {
263            if world_exists && has_renderer_trigger(world_path)? {
264                return Ok(Vec::new());
265            }
266            // No default scaffold: the renderer stack is injected at build
267            // time from the scene's own assets. Only an explicitly requested
268            // template writes extra entries.
269            Ok(template_entries.unwrap_or_default())
270        }
271    }
272}
273
274// Map a `--template <name>` value to its entries, looked up in the engine-owned
275// `concinnity_world::template` registry. Returns:
276//   - `Ok(None)`              when no template was requested (use default scaffold)
277//   - `Ok(Some(entries))`     when the named template is known
278//   - `Err(InvalidInput)`     when the name is unrecognised (typo → fail fast)
279fn resolve_template(template: Option<&str>) -> std::io::Result<Option<Vec<serde_json::Value>>> {
280    let Some(name) = template else {
281        return Ok(None);
282    };
283    match concinnity_world::template::by_name(name) {
284        Some(t) => Ok(Some(
285            crate::authoring::template_spec::world_template_entries(t),
286        )),
287        None => Err(std::io::Error::new(
288            std::io::ErrorKind::InvalidInput,
289            format!(
290                "unknown template '{name}'; available: {}",
291                available_templates()
292            ),
293        )),
294    }
295}
296
297// Comma-separated list of known template names, for the "unknown template" error.
298fn available_templates() -> String {
299    concinnity_world::template::TEMPLATES
300        .iter()
301        .map(|t| t.name)
302        .collect::<Vec<_>>()
303        .join(", ")
304}
305
306// Whether the JSONL file at `world_path` already renders on its own: it
307// declares GraphicsConfig or a type whose presence implies it at build time
308// (the registry's `renders` flag, which drives cook's GraphicsConfig companion
309// injection). When true the world will start the GraphicsSystem without any
310// scaffold. Malformed lines are skipped silently: the regular load path will
311// surface any parse problems with full diagnostics.
312fn has_renderer_trigger(world_path: &str) -> std::io::Result<bool> {
313    let content = std::fs::read_to_string(world_path)?;
314    Ok(jsonl_has_renderer_trigger(&content))
315}
316
317// Pure-string variant of `has_renderer_trigger`, exposed for unit tests.
318fn jsonl_has_renderer_trigger(content: &str) -> bool {
319    for line in content.lines() {
320        let line = line.trim();
321        if line.is_empty() {
322            continue;
323        }
324        let value: serde_json::Value = match serde_json::from_str(line) {
325            Ok(v) => v,
326            Err(_) => continue,
327        };
328        if let Some(t) = value.get("type").and_then(|v| v.as_str())
329            && concinnity_world::registry::type_renders(t)
330        {
331            return true;
332        }
333    }
334    false
335}
336
337// Make sure `world_path` (and its parent directory) exists so
338// `patch_world_jsonl_to` can read from it. Creates an empty file when missing;
339// no-op when the file already exists.
340fn ensure_world_file_exists(world_path: &str) -> std::io::Result<()> {
341    if std::path::Path::new(world_path).exists() {
342        return Ok(());
343    }
344    if let Some(parent) = std::path::Path::new(world_path).parent()
345        && !parent.as_os_str().is_empty()
346    {
347        std::fs::create_dir_all(parent)?;
348    }
349    std::fs::write(world_path, "")?;
350    tracing::info!("created empty world file at {}", world_path);
351    Ok(())
352}
353
354// How a target relates to renderer scaffolding. Targets that can bootstrap a
355// world from nothing fall into `Scene` (needs the GLB scaffold injected
356// alongside) or `Text` (the TextLabel that gets emitted is itself a renderer
357// trigger and its companions inject the rest of the stack). Everything else
358// is `None`: adding a shader or font into a missing world is rejected.
359pub(crate) enum BootstrapKind {
360    None,
361    Scene,
362    Text,
363}
364
365pub(crate) fn target_bootstrap_kind(target: &str) -> BootstrapKind {
366    let ext = std::path::Path::new(target)
367        .extension()
368        .and_then(|e| e.to_str())
369        .map(|e| e.to_ascii_lowercase());
370    match ext.as_deref() {
371        Some("glb") | Some("gltf") | Some("fbx") => BootstrapKind::Scene,
372        Some("txt") | Some("md") => BootstrapKind::Text,
373        _ => BootstrapKind::None,
374    }
375}
376
377pub(crate) fn is_path_like(s: &str) -> bool {
378    if s.contains('/') || s.contains('\\') {
379        return true;
380    }
381    if s.starts_with('.') || s.starts_with('~') {
382        return true;
383    }
384    // has a dot but the full string isn't a known type name
385    if s.contains('.') {
386        return RegisteredType::parse(s).is_none();
387    }
388    false
389}
390
391fn validated_entry(
392    name: &str,
393    asset_type: &str,
394    args: serde_json::Value,
395) -> std::io::Result<serde_json::Value> {
396    // A resource asset does not build a component def. Resolve its args against
397    // its registration (supplied over defaults) instead of `create_asset_def`.
398    if let Some(rt) = RegisteredType::parse(asset_type).filter(|t| t.is_resource()) {
399        let mut resolved = rt
400            .registration()
401            .default_args
402            .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
403        if let (serde_json::Value::Object(base), serde_json::Value::Object(supplied)) =
404            (&mut resolved, &args)
405        {
406            for (k, v) in supplied {
407                base.insert(k.clone(), v.clone());
408            }
409        }
410        return Ok(serde_json::json!({
411            "name": name,
412            "type": asset_type,
413            "args": resolved,
414        }));
415    }
416
417    let req = AssetRequest {
418        asset_type: asset_type.to_string(),
419        args: Some(args.clone()),
420    };
421    create_asset_def(&req)
422        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
423    let resolved_args = normalized_args_value(asset_type, &args);
424
425    Ok(serde_json::json!({
426        "name": name,
427        "type": asset_type,
428        "args": resolved_args,
429    }))
430}
431
432// The args JSON written into world.jsonl for a validated add: the supplied
433// args merged over the type's defaults (as `create_asset_def` resolves them)
434// and normalized through the typed schema. The def's baked bytes are postcard
435// and cannot round-trip to JSON.
436fn normalized_args_value(asset_type: &str, args: &serde_json::Value) -> serde_json::Value {
437    let empty = || serde_json::Value::Object(Default::default());
438    let Some(ct) = RegisteredType::parse(asset_type) else {
439        return empty();
440    };
441    let mut merged = ct.registration().default_args.unwrap_or_else(empty);
442    if let (serde_json::Value::Object(base), serde_json::Value::Object(supplied)) =
443        (&mut merged, args)
444    {
445        for (k, v) in supplied {
446            base.insert(k.clone(), v.clone());
447        }
448    }
449    ct.normalized_args(&merged).unwrap_or_else(|_| empty())
450}
451
452// Build an entry for a build-time (BuildOnly) import asset that expands from
453// a source file (SceneImport, StoryImport). Such an asset can't go through
454// `validated_entry` / `create_asset_def` (which only build External
455// components); materialize its default args from the registration and set the
456// source path.
457fn import_entry(asset_type: &str, name: &str, source: &str) -> std::io::Result<serde_json::Value> {
458    let reg = RegisteredType::parse(asset_type)
459        .ok_or_else(|| std::io::Error::other(format!("{} asset type is unavailable", asset_type)))?
460        .registration();
461    let mut args = reg
462        .default_args
463        .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
464    if let serde_json::Value::Object(map) = &mut args {
465        map.insert(
466            "source".to_string(),
467            serde_json::Value::String(source.to_string()),
468        );
469    }
470    Ok(serde_json::json!({
471        "name": name,
472        "type": asset_type,
473        "args": args,
474    }))
475}
476
477// Every file extension the dispatch below resolves, grouped for a file
478// picker's category filters (the editor's Import panel builds its dialog from
479// this). Kept beside the dispatch so a newly handled extension is offered by
480// the picker the same day the CLI learns it;
481// `import_extension_groups_track_the_dispatch` fails if one drifts out.
482pub(crate) const IMPORT_EXTENSION_GROUPS: &[(&str, &[&str])] = &[
483    ("Scenes", &["glb", "gltf", "fbx"]),
484    ("Stories & text", &["md", "txt"]),
485    ("Images", &["png", "jpg", "jpeg", "bmp", "tga", "gif"]),
486    ("Textures", &["ktx2"]),
487    ("Environment maps", &["hdr"]),
488    ("Audio", &["ogg", "wav", "mp3", "flac"]),
489    ("Fonts", &["ttf", "otf"]),
490    ("Shaders", &["vert", "frag", "glsl", "metal", "wgsl"]),
491    ("Models & data", &["obj", "mtl", "json", "gguf"]),
492];
493
494// Resolve a file path into its asset entries by extension. Shared by the CLI
495// add flow above and the editor's Import panel, so both produce identical
496// entries for the same file.
497pub(crate) fn entry_from_path(path_str: &str) -> std::io::Result<Vec<serde_json::Value>> {
498    let path = std::path::Path::new(path_str);
499
500    let ext = path
501        .extension()
502        .and_then(|e| e.to_str())
503        .map(|e| e.to_lowercase())
504        .unwrap_or_default();
505
506    // stem without extension, dots replaced with underscores (shared with
507    // companion injection so a generated default asset is named identically)
508    let stem = concinnity_cook::world::asset_name_from_path(path_str);
509
510    // full filename with dots replaced with underscores (used for most types)
511    let base_name = if ext == "json" {
512        stem.clone()
513    } else {
514        path.file_name()
515            .and_then(|s| s.to_str())
516            .map(|s| s.replace('.', "_"))
517            .unwrap_or_else(|| path_str.to_string())
518    };
519
520    if ext == "json" {
521        return entry_from_json_file(path, &base_name).map(|e| vec![e]);
522    }
523
524    match ext.as_str() {
525        // Dedicated-extension GLSL shaders: a Shader needs both stages, so
526        // the file is paired with its sibling stage file.
527        "vert" => {
528            let frag = sibling_path(path, "frag")?;
529            shader_pair_entry(&stem, path_str, &frag)
530        }
531        "frag" => {
532            let vert = sibling_path(path, "vert")?;
533            shader_pair_entry(&stem, &vert, path_str)
534        }
535
536        // GLSL: infer the stage from the filename stem and pair with the
537        // counterpart file (foo_vert.glsl <-> foo_frag.glsl).
538        "glsl" => {
539            let (vert, frag, pair_stem) = glsl_pair(path, path_str, &stem)?;
540            shader_pair_entry(&pair_stem, &vert, &frag)
541        }
542
543        // Metal: parse source to detect which stages are present
544        "metal" => {
545            let source = read_source_file(path_str)?;
546            shader_entries_from_stages(path_str, &stem, "metal", &detect_metal_stages(&source))
547        }
548
549        // WGSL: parse source to detect which stages are present
550        "wgsl" => {
551            let source = read_source_file(path_str)?;
552            shader_entries_from_stages(path_str, &stem, "wgsl", &detect_wgsl_stages(&source))
553        }
554
555        // Fonts: stem only, no extension suffix needed, font names won't conflict with shaders
556        "ttf" | "otf" => Ok(vec![validated_entry(
557            &stem,
558            "Font",
559            serde_json::json!({ "path": path_str }),
560        )?]),
561
562        // LLM weights
563        "gguf" => Ok(vec![validated_entry(
564            &base_name,
565            "LLM",
566            serde_json::json!({ "lib_path": "", "model_path": path_str }),
567        )?]),
568
569        // Audio files: an AudioClip whose payload is compiled (and decode-
570        // validated) at build. Played through an AudioEmitter (positional) or
571        // an AudioCue (view-triggered). Stem only, like fonts, so emitter and
572        // cue declarations read naturally.
573        "ogg" | "wav" | "mp3" | "flac" => Ok(vec![validated_entry(
574            &stem,
575            "AudioClip",
576            serde_json::json!({ "source": path_str }),
577        )?]),
578
579        // Radiance HDR: an EnvironmentMap, whose build convolves the
580        // equirectangular source into the irradiance + prefiltered radiance
581        // cubemaps that light the scene. Its presence also injects the skybox
582        // mesh that displays it (see cook's `inject_sky`). Stem only, like
583        // fonts and audio.
584        "hdr" => Ok(vec![environment_map_entry(&stem, path_str)?]),
585
586        // KTX2: a GPU-ready compressed texture. Becomes a Texture asset whose
587        // source the build compiles into a block-compressed payload (BCn / Basis
588        // transcode), unlike the raw File assets below.
589        "ktx2" => Ok(vec![validated_entry(
590            &base_name,
591            "Texture",
592            serde_json::json!({ "source": path_str }),
593        )?]),
594
595        // File-backed assets: path is stored as-is; build compiles the blob
596        "obj" | "mtl" | "png" | "jpg" | "jpeg" | "bmp" | "tga" | "gif" => {
597            Ok(vec![validated_entry(
598                &base_name,
599                "File",
600                serde_json::json!({ "path": path_str, "kind": ext.as_str() }),
601            )?])
602        }
603
604        // Text files become a TextLabel carrying the file contents. The label
605        // is its own renderer trigger (the registry's `renders` flag) and
606        // companion injection adds GraphicsSystem, so a fresh `cn add notes.txt`
607        // lands a renderable world without a scaffold. Naming no Font draws it
608        // with the built-in face.
609        //
610        // `centered: true` keeps the contents off the HUD chips in the top-left
611        // corner, and matches what `cn init` writes.
612        "txt" => Ok(vec![text_label_entry(&stem, path_str)?]),
613
614        // Markdown: a file opening with a frontmatter fence is a story and
615        // becomes one StoryImport line the build expands into its UI assets;
616        // plain Markdown is treated as text, same as `.txt`.
617        "md" => {
618            if md_has_frontmatter(path_str)? {
619                Ok(vec![import_entry("StoryImport", &stem, path_str)?])
620            } else {
621                Ok(vec![text_label_entry(&stem, path_str)?])
622            }
623        }
624
625        // 3D scene files: one SceneImport line. The build expands it into
626        // Textures / Materials / Meshes / Models / Props at compile time, so
627        // world.jsonl stays compact (see concinnity_core::build::import).
628        //
629        // A `.glb` is checked for the panorama-sphere packaging first: those
630        // files carry an environment image, not geometry, and importing one as
631        // a mesh puts a ball in the scene where a sky belongs.
632        "glb" | "gltf" => Ok(vec![scene_or_panorama_entry(&stem, path_str)?]),
633        "fbx" => Ok(vec![import_entry("SceneImport", &stem, path_str)?]),
634
635        other => Err(std::io::Error::new(
636            std::io::ErrorKind::InvalidInput,
637            format!(
638                "unknown extension '.{}'; pass a type name instead \
639                 (e.g. `concinnity add Logger`) or edit {} directly",
640                other, WORLD_JSONL
641            ),
642        )),
643    }
644}
645
646// A `.glb` / `.gltf` becomes an EnvironmentMap when it is a panorama sphere
647// (see `concinnity_cook::panorama`) and scene geometry otherwise. The choice
648// is logged because the same extension lands two different asset types.
649fn scene_or_panorama_entry(stem: &str, path_str: &str) -> std::io::Result<serde_json::Value> {
650    if concinnity_cook::panorama::file_is_panorama_sphere(path_str) {
651        tracing::info!(
652            "'{}' is a panorama sphere: importing it as an EnvironmentMap (sky \
653             and image-based lighting) rather than scene geometry",
654            path_str
655        );
656        return environment_map_entry(stem, path_str);
657    }
658    import_entry("SceneImport", stem, path_str)
659}
660
661fn environment_map_entry(stem: &str, path_str: &str) -> std::io::Result<serde_json::Value> {
662    validated_entry(
663        stem,
664        "EnvironmentMap",
665        serde_json::json!({ "source": path_str }),
666    )
667}
668
669// Build one Shader entry from a source file that must carry both stages.
670// Named "{stem}_shader", with both stages reading the same source file.
671fn shader_entries_from_stages(
672    path_str: &str,
673    stem: &str,
674    ext: &str,
675    stages: &[&str],
676) -> std::io::Result<Vec<serde_json::Value>> {
677    if !(stages.contains(&"vertex") && stages.contains(&"fragment")) {
678        return Err(std::io::Error::new(
679            std::io::ErrorKind::InvalidInput,
680            format!(
681                "a Shader needs both a vertex and a fragment stage, but the .{ext} \
682                 source declares only {stages:?}. Add the missing stage function, or \
683                 declare a Shader entry in world.jsonl with per-stage sources"
684            ),
685        ));
686    }
687    shader_pair_entry(stem, path_str, path_str)
688}
689
690// One Shader entry named "{stem}_shader" from a vertex + fragment source pair
691// (the two paths are the same file for multi-stage sources).
692fn shader_pair_entry(
693    stem: &str,
694    vert_path: &str,
695    frag_path: &str,
696) -> std::io::Result<Vec<serde_json::Value>> {
697    Ok(vec![validated_entry(
698        &format!("{stem}_shader"),
699        "Shader",
700        serde_json::json!({
701            "vertex": { "source": vert_path },
702            "fragment": { "source": frag_path },
703        }),
704    )?])
705}
706
707// The sibling stage file next to a dedicated-extension GLSL shader
708// (x.vert <-> x.frag). Errors when the counterpart is missing: half a shader
709// program cannot render.
710fn sibling_path(path: &std::path::Path, sibling_ext: &str) -> std::io::Result<String> {
711    let sibling = path.with_extension(sibling_ext);
712    if !sibling.exists() {
713        return Err(std::io::Error::new(
714            std::io::ErrorKind::NotFound,
715            format!(
716                "a Shader needs both stages: expected the {} stage next to {} \
717                 (looked for {})",
718                if sibling_ext == "vert" {
719                    "vertex"
720                } else {
721                    "fragment"
722                },
723                path.display(),
724                sibling.display(),
725            ),
726        ));
727    }
728    Ok(sibling.to_string_lossy().into_owned())
729}
730
731// Pair a .glsl file with its counterpart stage by swapping the stage marker in
732// the filename stem (foo_vert.glsl <-> foo_frag.glsl). Returns (vertex,
733// fragment) source paths plus the marker-stripped stem, so adding either file
734// of the pair produces the same Shader entry name.
735fn glsl_pair(
736    path: &std::path::Path,
737    path_str: &str,
738    stem: &str,
739) -> std::io::Result<(String, String, String)> {
740    let (this_marker, other_marker) = if stem.contains("fragment") {
741        ("fragment", "vertex")
742    } else if stem.contains("frag") {
743        ("frag", "vert")
744    } else if stem.contains("vertex") {
745        ("vertex", "fragment")
746    } else if stem.contains("vert") {
747        ("vert", "frag")
748    } else {
749        return Err(std::io::Error::new(
750            std::io::ErrorKind::InvalidInput,
751            format!(
752                "cannot infer the shader stage of {path_str}: name the files with \
753                 vert/frag markers (foo_vert.glsl + foo_frag.glsl), or declare a \
754                 Shader entry in world.jsonl with per-stage sources"
755            ),
756        ));
757    };
758    let file_name = path
759        .file_name()
760        .and_then(|s| s.to_str())
761        .unwrap_or(path_str);
762    let counterpart = path.with_file_name(file_name.replace(this_marker, other_marker));
763    if !counterpart.exists() {
764        return Err(std::io::Error::new(
765            std::io::ErrorKind::NotFound,
766            format!(
767                "a Shader needs both stages: expected the {other_marker} counterpart \
768                 of {path_str} (looked for {})",
769                counterpart.display()
770            ),
771        ));
772    }
773    let counterpart = counterpart.to_string_lossy().into_owned();
774    let this = path_str.to_string();
775    let pair_stem = stem
776        .replace(this_marker, "")
777        .trim_matches('_')
778        .replace("__", "_");
779    let (vert, frag) = if this_marker.starts_with('v') {
780        (this, counterpart)
781    } else {
782        (counterpart, this)
783    };
784    Ok((vert, frag, pair_stem))
785}
786
787// Detect Metal pipeline stages from source text.
788// Metal uses `vertex` / `fragment` as function-qualifier keywords at the start of declarations.
789// Returns a non-empty list; defaults to ["vertex"] when no qualifiers are found.
790pub(crate) fn detect_metal_stages(source: &str) -> Vec<&'static str> {
791    let has_vertex = source.lines().any(|l| {
792        let t = l.trim_start();
793        t.starts_with("vertex ") || t.starts_with("vertex\t")
794    });
795    let has_fragment = source.lines().any(|l| {
796        let t = l.trim_start();
797        t.starts_with("fragment ") || t.starts_with("fragment\t")
798    });
799    stages_from_flags(has_vertex, has_fragment)
800}
801
802// Detect WGSL pipeline stages from source text.
803// WGSL uses `@vertex` / `@fragment` attribute decorators.
804// Returns a non-empty list; defaults to ["vertex"] when no attributes are found.
805pub(crate) fn detect_wgsl_stages(source: &str) -> Vec<&'static str> {
806    stages_from_flags(source.contains("@vertex"), source.contains("@fragment"))
807}
808
809fn stages_from_flags(has_vertex: bool, has_fragment: bool) -> Vec<&'static str> {
810    let mut stages = Vec::new();
811    if has_vertex {
812        stages.push("vertex");
813    }
814    if has_fragment {
815        stages.push("fragment");
816    }
817    if stages.is_empty() {
818        stages.push("vertex");
819    }
820    stages
821}
822
823fn read_source_file(path_str: &str) -> std::io::Result<String> {
824    std::fs::read_to_string(path_str)
825        .map_err(|e| std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e)))
826}
827
828// Cap text-label contents at a size that makes sense for a HUD overlay. A
829// TextLabel isn't a document viewer; multi-MB drops would silently bloat
830// world.jsonl and the per-frame layout pass.
831const TEXT_LABEL_MAX_BYTES: usize = 64 * 1024;
832
833// Read a `.txt` / `.md` file for use as TextLabel `content`. Strips a single
834// trailing `\n` (and a preceding `\r`, in case the file is CRLF) so labels
835// don't render with a stray blank line at the bottom, but otherwise preserves
836// internal whitespace and newlines verbatim. Files larger than
837// `TEXT_LABEL_MAX_BYTES` are rejected up front rather than truncated, so the
838// user knows the contents weren't silently clipped.
839fn read_text_content(path_str: &str) -> std::io::Result<String> {
840    let metadata = std::fs::metadata(path_str).map_err(|e| {
841        std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e))
842    })?;
843    if metadata.len() as usize > TEXT_LABEL_MAX_BYTES {
844        return Err(std::io::Error::new(
845            std::io::ErrorKind::InvalidInput,
846            format!(
847                "'{}' is {} bytes; TextLabel content is capped at {} bytes: \
848                 trim the file or add it as a `File` asset via inline JSON instead",
849                path_str,
850                metadata.len(),
851                TEXT_LABEL_MAX_BYTES
852            ),
853        ));
854    }
855    let mut content = std::fs::read_to_string(path_str).map_err(|e| {
856        std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e))
857    })?;
858    if content.ends_with('\n') {
859        content.pop();
860        if content.ends_with('\r') {
861            content.pop();
862        }
863    }
864    Ok(content)
865}
866
867fn text_label_entry(name: &str, path_str: &str) -> std::io::Result<serde_json::Value> {
868    validated_entry(
869        name,
870        "TextLabel",
871        serde_json::json!({
872            "content": read_text_content(path_str)?,
873            "centered": true,
874        }),
875    )
876}
877
878// A Markdown story opens with a `---` frontmatter fence on its first line.
879// Detection reads only that line, so the TextLabel size cap never applies to
880// a story file.
881fn md_has_frontmatter(path_str: &str) -> std::io::Result<bool> {
882    use std::io::BufRead;
883    let file = std::fs::File::open(path_str).map_err(|e| {
884        std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e))
885    })?;
886    let mut first = String::new();
887    std::io::BufReader::new(file).read_line(&mut first)?;
888    Ok(first.trim_start_matches('\u{feff}').trim_end() == "---")
889}
890
891fn entry_from_json_file(
892    path: &std::path::Path,
893    stem_name: &str,
894) -> std::io::Result<serde_json::Value> {
895    let content = std::fs::read_to_string(path).map_err(|e| {
896        std::io::Error::new(
897            e.kind(),
898            format!("could not read '{}': {}", path.display(), e),
899        )
900    })?;
901    let json: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
902        std::io::Error::new(
903            std::io::ErrorKind::InvalidData,
904            format!("could not parse '{}': {}", path.display(), e),
905        )
906    })?;
907
908    let asset_type = json.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
909        std::io::Error::new(
910            std::io::ErrorKind::InvalidData,
911            format!("'{}' has no `type` field", path.display()),
912        )
913    })?;
914
915    if asset_type.to_lowercase().replace('_', "") == "buildconfig" {
916        return Err(std::io::Error::new(
917            std::io::ErrorKind::InvalidInput,
918            format!(
919                "BuildConfig cannot be added via `concinnity add`; edit {} directly",
920                WORLD_JSONL
921            ),
922        ));
923    }
924
925    let args = json
926        .get("args")
927        .cloned()
928        .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
929
930    let name = json
931        .get("name")
932        .and_then(|v| v.as_str())
933        .unwrap_or(stem_name)
934        .to_string();
935
936    validated_entry(&name, asset_type, args)
937}
938
939fn entry_from_inline_json(raw: &str) -> std::io::Result<serde_json::Value> {
940    let json: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
941        std::io::Error::new(
942            std::io::ErrorKind::InvalidData,
943            format!("could not parse inline JSON: {}", e),
944        )
945    })?;
946
947    if !json.is_object() {
948        return Err(std::io::Error::new(
949            std::io::ErrorKind::InvalidData,
950            "inline JSON must be an object (e.g. '{\"type\": \"Window\"}')",
951        ));
952    }
953
954    let asset_type = json.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
955        std::io::Error::new(
956            std::io::ErrorKind::InvalidData,
957            "inline JSON must contain a `type` field",
958        )
959    })?;
960
961    if asset_type.to_lowercase().replace('_', "") == "buildconfig" {
962        return Err(std::io::Error::new(
963            std::io::ErrorKind::InvalidInput,
964            format!(
965                "BuildConfig cannot be added via `concinnity add`; edit {} directly",
966                WORLD_JSONL
967            ),
968        ));
969    }
970
971    let name = json
972        .get("name")
973        .and_then(|v| v.as_str())
974        .map(str::to_string)
975        .unwrap_or_else(|| asset_type.to_lowercase());
976
977    let args = json
978        .get("args")
979        .cloned()
980        .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
981
982    let req = AssetRequest {
983        asset_type: asset_type.to_string(),
984        args: Some(args.clone()),
985    };
986    create_asset_def(&req)
987        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
988    let resolved_args = normalized_args_value(asset_type, &args);
989
990    Ok(serde_json::json!({
991        "name": name,
992        "type": asset_type,
993        "args": resolved_args,
994    }))
995}
996
997// Resolve an add target -- a file path, a known asset type name, or inline
998// JSON -- into its world entries. Shared with the editor console's /add, so a
999// console add and a CLI add accept the same targets.
1000pub(crate) fn resolve_add_target(target: &str) -> std::io::Result<Vec<serde_json::Value>> {
1001    if is_path_like(target) {
1002        return entry_from_path(target);
1003    }
1004
1005    if RegisteredType::parse(target).is_some() {
1006        return entry_from_type_name(target).map(|e| vec![e]);
1007    }
1008
1009    let as_path = std::path::Path::new(target);
1010    if as_path.exists() && as_path.is_file() {
1011        let name = as_path
1012            .file_name()
1013            .and_then(|s| s.to_str())
1014            .map(|s| s.replace('.', "_"))
1015            .unwrap_or_else(|| target.to_string());
1016        return entry_from_json_file(as_path, &name).map(|e| vec![e]);
1017    }
1018
1019    if target.trim_start().starts_with('{') {
1020        return entry_from_inline_json(target).map(|e| vec![e]);
1021    }
1022
1023    Err(std::io::Error::new(
1024        std::io::ErrorKind::InvalidInput,
1025        format!(
1026            "could not resolve '{}' as any of:\n  \
1027             - a file path (no such file found)\n  \
1028             - a known asset type (use `concinnity list` to see available types)\n  \
1029             - an inline JSON object (must start with '{{' and contain a `type` field)",
1030            target
1031        ),
1032    ))
1033}
1034
1035fn entry_from_type_name(type_str: &str) -> std::io::Result<serde_json::Value> {
1036    if type_str.to_lowercase().replace('_', "") == "buildconfig" {
1037        return Err(std::io::Error::new(
1038            std::io::ErrorKind::InvalidInput,
1039            format!(
1040                "BuildConfig cannot be added via `concinnity add`; edit {} directly",
1041                WORLD_JSONL
1042            ),
1043        ));
1044    }
1045
1046    let req = AssetRequest {
1047        asset_type: type_str.to_string(),
1048        args: None,
1049    };
1050    create_asset_def(&req)
1051        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
1052    let args = normalized_args_value(type_str, &serde_json::Value::Object(Default::default()));
1053
1054    let name = type_str.to_lowercase();
1055
1056    Ok(serde_json::json!({
1057        "name": name,
1058        "type": type_str,
1059        "args": args,
1060    }))
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065    use super::*;
1066
1067    // detect_metal_stages
1068
1069    #[test]
1070    fn metal_both_stages() {
1071        let src = "vertex VertexOut vert_main() {}\nfragment float4 frag_main() {}";
1072        assert_eq!(detect_metal_stages(src), vec!["vertex", "fragment"]);
1073    }
1074
1075    #[test]
1076    fn metal_vertex_only() {
1077        let src = "vertex VertexOut vert_main() {}";
1078        assert_eq!(detect_metal_stages(src), vec!["vertex"]);
1079    }
1080
1081    #[test]
1082    fn metal_fragment_only() {
1083        let src = "fragment float4 frag_main() {}";
1084        assert_eq!(detect_metal_stages(src), vec!["fragment"]);
1085    }
1086
1087    #[test]
1088    fn metal_no_qualifiers_defaults_to_vertex() {
1089        let src = "// helper only\nfloat4 helper() { return float4(1.0); }";
1090        assert_eq!(detect_metal_stages(src), vec!["vertex"]);
1091    }
1092
1093    #[test]
1094    fn metal_tab_separated_qualifier() {
1095        let src = "vertex\tVertexOut vert_main() {}";
1096        assert_eq!(detect_metal_stages(src), vec!["vertex"]);
1097    }
1098
1099    #[test]
1100    fn metal_indented_qualifier_still_detected() {
1101        // Metal qualifiers can appear with leading whitespace (e.g. inside a namespace-like block)
1102        let src = "  vertex VertexOut vert_main() {}\n  fragment float4 frag_main() {}";
1103        assert_eq!(detect_metal_stages(src), vec!["vertex", "fragment"]);
1104    }
1105
1106    // detect_wgsl_stages
1107
1108    #[test]
1109    fn wgsl_both_stages() {
1110        let src =
1111            "@vertex\nfn vs() -> VertexOutput {}\n@fragment\nfn fs() -> @location(0) vec4<f32> {}";
1112        assert_eq!(detect_wgsl_stages(src), vec!["vertex", "fragment"]);
1113    }
1114
1115    #[test]
1116    fn wgsl_vertex_only() {
1117        let src = "@vertex fn vs() {}";
1118        assert_eq!(detect_wgsl_stages(src), vec!["vertex"]);
1119    }
1120
1121    #[test]
1122    fn wgsl_no_attributes_defaults_to_vertex() {
1123        let src = "fn helper() -> f32 { return 1.0; }";
1124        assert_eq!(detect_wgsl_stages(src), vec!["vertex"]);
1125    }
1126
1127    #[test]
1128    fn a_two_stage_source_becomes_one_shader_entry() {
1129        let entries =
1130            shader_entries_from_stages("s.metal", "s", "metal", &["vertex", "fragment"]).unwrap();
1131        assert_eq!(entries.len(), 1);
1132        assert_eq!(entries[0]["name"], "s_shader");
1133        assert_eq!(entries[0]["type"], "Shader");
1134        assert_eq!(entries[0]["args"]["vertex"]["source"], "s.metal");
1135        assert_eq!(entries[0]["args"]["fragment"]["source"], "s.metal");
1136    }
1137
1138    #[test]
1139    fn a_single_stage_source_is_rejected_with_guidance() {
1140        let err = shader_entries_from_stages("s.metal", "s", "metal", &["vertex"]).unwrap_err();
1141        assert!(err.to_string().contains("both a vertex and a fragment"));
1142    }
1143
1144    #[test]
1145    fn glsl_pair_swaps_the_stage_marker_and_shares_a_stem() {
1146        let dir = tempfile::tempdir().unwrap();
1147        let vert = dir.path().join("foo_vert.glsl");
1148        let frag = dir.path().join("foo_frag.glsl");
1149        std::fs::write(&vert, "").unwrap();
1150        std::fs::write(&frag, "").unwrap();
1151
1152        // Adding either file of the pair resolves the same (vert, frag, stem).
1153        let from_vert = glsl_pair(&vert, vert.to_str().unwrap(), "foo_vert").unwrap();
1154        let from_frag = glsl_pair(&frag, frag.to_str().unwrap(), "foo_frag").unwrap();
1155        assert_eq!(from_vert.0, vert.to_str().unwrap());
1156        assert_eq!(from_vert.1, frag.to_str().unwrap());
1157        assert_eq!(from_vert.0, from_frag.0);
1158        assert_eq!(from_vert.1, from_frag.1);
1159        assert_eq!(from_vert.2, "foo");
1160        assert_eq!(from_frag.2, "foo");
1161
1162        // A missing counterpart is an error, not half a Shader.
1163        std::fs::remove_file(&frag).unwrap();
1164        assert!(glsl_pair(&vert, vert.to_str().unwrap(), "foo_vert").is_err());
1165    }
1166
1167    // font stem naming
1168
1169    #[test]
1170    fn font_name_uses_stem_only() {
1171        let path = std::path::Path::new("fonts/JetBrainsMono-Regular.ttf");
1172        let stem = path
1173            .file_stem()
1174            .and_then(|s| s.to_str())
1175            .map(|s| s.replace('.', "_"))
1176            .unwrap_or_default();
1177        // stem should not include the .ttf extension
1178        assert_eq!(stem, "JetBrainsMono-Regular");
1179        assert!(!stem.contains("ttf"));
1180    }
1181
1182    // unknown extension
1183
1184    #[test]
1185    fn unknown_extension_errors() {
1186        // Use a path that won't exist on disk so it goes through entry_from_path
1187        let result = entry_from_path("assets/shader.xyz");
1188        assert!(result.is_err());
1189        let msg = result.unwrap_err().to_string();
1190        assert!(msg.contains(".xyz"));
1191    }
1192
1193    // stages_from_flags edge cases
1194
1195    #[test]
1196    fn stages_from_flags_neither() {
1197        assert_eq!(stages_from_flags(false, false), vec!["vertex"]);
1198    }
1199
1200    #[test]
1201    fn stages_from_flags_both() {
1202        assert_eq!(stages_from_flags(true, true), vec!["vertex", "fragment"]);
1203    }
1204
1205    #[test]
1206    fn stages_from_flags_fragment_only() {
1207        assert_eq!(stages_from_flags(false, true), vec!["fragment"]);
1208    }
1209
1210    // target_bootstrap_kind
1211
1212    #[test]
1213    fn target_bootstrap_kind_glb_is_scene() {
1214        assert!(matches!(
1215            target_bootstrap_kind("models/scene.glb"),
1216            BootstrapKind::Scene
1217        ));
1218        assert!(matches!(
1219            target_bootstrap_kind("scene.GLB"),
1220            BootstrapKind::Scene
1221        ));
1222    }
1223
1224    #[test]
1225    fn target_bootstrap_kind_text_is_text() {
1226        assert!(matches!(
1227            target_bootstrap_kind("notes.txt"),
1228            BootstrapKind::Text
1229        ));
1230        assert!(matches!(
1231            target_bootstrap_kind("README.MD"),
1232            BootstrapKind::Text
1233        ));
1234    }
1235
1236    #[test]
1237    fn target_bootstrap_kind_other_is_none() {
1238        assert!(matches!(
1239            target_bootstrap_kind("Logger"),
1240            BootstrapKind::None
1241        ));
1242        assert!(matches!(
1243            target_bootstrap_kind("shader.vert"),
1244            BootstrapKind::None
1245        ));
1246        assert!(matches!(
1247            target_bootstrap_kind("font.ttf"),
1248            BootstrapKind::None
1249        ));
1250    }
1251
1252    // jsonl_has_renderer_trigger
1253
1254    #[test]
1255    fn renderer_trigger_matches_graphics_config() {
1256        let jsonl = r#"{"name":"gc","type":"GraphicsConfig","args":{}}"#;
1257        assert!(jsonl_has_renderer_trigger(jsonl));
1258    }
1259
1260    #[test]
1261    fn renderer_trigger_matches_text_label() {
1262        let jsonl = r#"{"name":"lbl","type":"TextLabel","args":{}}"#;
1263        assert!(jsonl_has_renderer_trigger(jsonl));
1264    }
1265
1266    // A Prop implies the world renders: cook injects the GraphicsConfig marker
1267    // for it, so no scaffold is needed.
1268    #[test]
1269    fn renderer_trigger_matches_prop() {
1270        let jsonl = r#"{"name":"crate","type":"Prop","args":{}}"#;
1271        assert!(jsonl_has_renderer_trigger(jsonl));
1272    }
1273
1274    // A bare Window does NOT start the renderer (cook injects no GraphicsConfig
1275    // for it), so it is not a trigger.
1276    #[test]
1277    fn renderer_trigger_ignores_window() {
1278        let jsonl = r#"{"name":"win","type":"Window","args":{}}"#;
1279        assert!(!jsonl_has_renderer_trigger(jsonl));
1280    }
1281
1282    #[test]
1283    fn renderer_trigger_absent_for_render_data_only_world() {
1284        // Exactly the shape that produced the no-window bug: textures /
1285        // materials / meshes / models / camera but no renderer-trigger.
1286        let jsonl = concat!(
1287            r#"{"name":"tex","type":"Texture","args":{}}"#,
1288            "\n",
1289            r#"{"name":"mat","type":"Material","args":{}}"#,
1290            "\n",
1291            r#"{"name":"mesh","type":"Mesh","args":{}}"#,
1292            "\n",
1293            r#"{"name":"model","type":"Model","args":{}}"#,
1294            "\n",
1295            r#"{"name":"cam","type":"Camera3D","args":{}}"#,
1296            "\n",
1297        );
1298        assert!(!jsonl_has_renderer_trigger(jsonl));
1299    }
1300
1301    #[test]
1302    fn renderer_trigger_skips_malformed_lines() {
1303        let jsonl = concat!(
1304            "garbage line\n",
1305            r#"{"name":"tex","type":"Texture","args":{}}"#,
1306            "\n",
1307        );
1308        assert!(!jsonl_has_renderer_trigger(jsonl));
1309    }
1310
1311    #[test]
1312    fn renderer_trigger_handles_empty_jsonl() {
1313        assert!(!jsonl_has_renderer_trigger(""));
1314    }
1315
1316    // scaffold_to_inject
1317
1318    #[test]
1319    fn scaffold_to_inject_writes_nothing_without_a_template() {
1320        // Existing world with renderer-less assets + a .glb target: the
1321        // renderer stack is injected at build time, so no lines are written.
1322        let dir =
1323            std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1324        std::fs::create_dir_all(&dir).unwrap();
1325        let world = dir.join("world.jsonl");
1326        std::fs::write(
1327            &world,
1328            concat!(
1329                r#"{"name":"tex","type":"Texture","args":{}}"#,
1330                "\n",
1331                r#"{"name":"cam","type":"Camera3D","args":{}}"#,
1332                "\n",
1333            ),
1334        )
1335        .unwrap();
1336
1337        let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", None).unwrap();
1338        assert!(scaffold.is_empty());
1339
1340        let _ = std::fs::remove_dir_all(&dir);
1341    }
1342
1343    #[test]
1344    fn scaffold_to_inject_returns_empty_when_world_has_graphics_config() {
1345        let dir =
1346            std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1347        std::fs::create_dir_all(&dir).unwrap();
1348        let world = dir.join("world.jsonl");
1349        std::fs::write(
1350            &world,
1351            r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
1352        )
1353        .unwrap();
1354
1355        let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", None).unwrap();
1356        assert!(scaffold.is_empty());
1357
1358        let _ = std::fs::remove_dir_all(&dir);
1359    }
1360
1361    #[test]
1362    fn scaffold_to_inject_allows_missing_world_with_glb() {
1363        // No file present, target is `.glb`: the caller is expected to create
1364        // the file; the renderer stack comes from build-time injection, so no
1365        // template entries are needed.
1366        let dir =
1367            std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1368        let _ = std::fs::remove_dir_all(&dir);
1369        let world = dir.join("world.jsonl");
1370
1371        let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", None).unwrap();
1372        assert!(scaffold.is_empty());
1373    }
1374
1375    #[test]
1376    fn scaffold_to_inject_errors_for_missing_world_with_non_scene_target() {
1377        let dir =
1378            std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1379        let _ = std::fs::remove_dir_all(&dir);
1380        let world = dir.join("world.jsonl");
1381
1382        let err = scaffold_to_inject(world.to_str().unwrap(), "Logger", None)
1383            .expect_err("missing world + non-scene target must error");
1384        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1385    }
1386
1387    #[test]
1388    fn scaffold_to_inject_returns_empty_for_non_scene_target_into_existing_world() {
1389        let dir =
1390            std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1391        std::fs::create_dir_all(&dir).unwrap();
1392        let world = dir.join("world.jsonl");
1393        std::fs::write(&world, "").unwrap();
1394
1395        // Non-scene target shouldn't trigger scaffolding even when the world
1396        // has no renderer: we don't try to guess intent for shaders/fonts.
1397        let scaffold = scaffold_to_inject(world.to_str().unwrap(), "Logger", None).unwrap();
1398        assert!(scaffold.is_empty());
1399
1400        let _ = std::fs::remove_dir_all(&dir);
1401    }
1402
1403    // template dispatch
1404
1405    #[test]
1406    fn scaffold_to_inject_uses_named_template() {
1407        let dir =
1408            std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1409        let _ = std::fs::remove_dir_all(&dir);
1410        let world = dir.join("world.jsonl");
1411
1412        let name = concinnity_world::template::TEMPLATES[0].name;
1413        let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", Some(name))
1414            .expect("a named template should apply for a renderer-less glb add");
1415        let expected = concinnity_world::template::by_name(name)
1416            .unwrap()
1417            .assets()
1418            .len();
1419        assert_eq!(scaffold.len(), expected, "expected the template's entries");
1420        assert!(!scaffold.is_empty());
1421    }
1422
1423    // Every entry of every engine-owned template validates as a real, buildable
1424    // asset with its declared args. This is the typed round-trip the templates
1425    // crate (pure data) cannot do itself: it guards against a template naming a
1426    // type that doesn't exist or shipping args the asset rejects, and against the
1427    // spec builders drifting from the real asset schemas.
1428    #[test]
1429    fn every_template_entry_validates_as_a_real_asset() {
1430        let _guard = crate::test_support::lock();
1431        for t in concinnity_world::template::TEMPLATES {
1432            let entries = crate::authoring::template_spec::world_template_entries(t);
1433            assert!(!entries.is_empty(), "template '{}' is empty", t.name);
1434            for entry in entries {
1435                let ty = entry["type"].as_str().expect("entry has a type");
1436                let name = entry["name"].as_str().expect("entry has a name");
1437                let args = entry
1438                    .get("args")
1439                    .cloned()
1440                    .unwrap_or_else(|| serde_json::json!({}));
1441                concinnity_cook::validate_asset(ty, name, &args).unwrap_or_else(|e| {
1442                    panic!(
1443                        "template '{}' entry '{name}' ({ty}) failed to validate: {e}",
1444                        t.name
1445                    )
1446                });
1447            }
1448        }
1449    }
1450
1451    #[test]
1452    fn scaffold_to_inject_errors_on_unknown_template() {
1453        let dir =
1454            std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1455        let _ = std::fs::remove_dir_all(&dir);
1456        let world = dir.join("world.jsonl");
1457
1458        let err = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", Some("nope"))
1459            .expect_err("unknown template name must surface as InvalidInput");
1460        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1461        assert!(
1462            err.to_string().contains("nope"),
1463            "error should name the bad template: {err}"
1464        );
1465    }
1466
1467    #[test]
1468    fn scaffold_to_inject_rejects_unknown_template_even_when_no_scaffold_would_fire() {
1469        // An unknown template name is a typo, full stop. We don't want the
1470        // user to silently get nothing when they intended to ask for a
1471        // template.
1472        let dir =
1473            std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1474        std::fs::create_dir_all(&dir).unwrap();
1475        let world = dir.join("world.jsonl");
1476        // Existing world with a renderer trigger: scaffolding wouldn't fire.
1477        std::fs::write(
1478            &world,
1479            r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
1480        )
1481        .unwrap();
1482
1483        let err = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", Some("nope"))
1484            .expect_err("unknown template should fail fast regardless of scaffold path");
1485        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1486
1487        let _ = std::fs::remove_dir_all(&dir);
1488    }
1489
1490    // text-file targets
1491
1492    fn text_test_dir(line: u32) -> std::path::PathBuf {
1493        let dir =
1494            std::env::temp_dir().join(format!("cn_add_text_test_{}_{}", std::process::id(), line));
1495        let _ = std::fs::remove_dir_all(&dir);
1496        std::fs::create_dir_all(&dir).unwrap();
1497        dir
1498    }
1499
1500    #[test]
1501    fn text_file_becomes_text_label_with_content() {
1502        let dir = text_test_dir(line!());
1503        let path = dir.join("greeting.txt");
1504        std::fs::write(&path, "Hello, world!").unwrap();
1505
1506        let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1507        assert_eq!(entries.len(), 1);
1508        let entry = &entries[0];
1509        assert_eq!(entry["type"], "TextLabel");
1510        assert_eq!(entry["name"], "greeting");
1511        assert_eq!(entry["args"]["content"], "Hello, world!");
1512        // Mirrors `cn init`: short labels render centered by default.
1513        assert_eq!(entry["args"]["centered"], serde_json::json!(true));
1514
1515        let _ = std::fs::remove_dir_all(&dir);
1516    }
1517
1518    #[test]
1519    fn text_file_strips_single_trailing_newline() {
1520        let dir = text_test_dir(line!());
1521        let lf = dir.join("lf.txt");
1522        std::fs::write(&lf, "line one\nline two\n").unwrap();
1523        let crlf = dir.join("crlf.md");
1524        std::fs::write(&crlf, "line one\r\nline two\r\n").unwrap();
1525
1526        let lf_entry = &entry_from_path(lf.to_str().unwrap()).unwrap()[0];
1527        assert_eq!(lf_entry["args"]["content"], "line one\nline two");
1528        let crlf_entry = &entry_from_path(crlf.to_str().unwrap()).unwrap()[0];
1529        assert_eq!(crlf_entry["args"]["content"], "line one\r\nline two");
1530
1531        let _ = std::fs::remove_dir_all(&dir);
1532    }
1533
1534    #[test]
1535    fn audio_file_becomes_audio_clip() {
1536        let dir = text_test_dir(line!());
1537        let path = dir.join("door_creak.wav");
1538        std::fs::write(&path, b"not-really-audio").unwrap();
1539
1540        let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1541        assert_eq!(entries.len(), 1);
1542        let entry = &entries[0];
1543        assert_eq!(entry["type"], "AudioClip");
1544        assert_eq!(entry["name"], "door_creak");
1545        assert_eq!(entry["args"]["source"], path.to_str().unwrap());
1546
1547        let _ = std::fs::remove_dir_all(&dir);
1548    }
1549
1550    #[test]
1551    fn hdr_file_becomes_an_environment_map() {
1552        let dir = text_test_dir(line!());
1553        let path = dir.join("san_giuseppe_4k.hdr");
1554        std::fs::write(&path, b"not-really-radiance").unwrap();
1555
1556        let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1557        assert_eq!(entries.len(), 1);
1558        let entry = &entries[0];
1559        assert_eq!(entry["type"], "EnvironmentMap");
1560        assert_eq!(entry["name"], "san_giuseppe_4k");
1561        assert_eq!(entry["args"]["source"], path.to_str().unwrap());
1562        // `source` and `generator` are mutually exclusive: the file source wins,
1563        // so the schema default leaves `generator` blank.
1564        assert_eq!(entry["args"]["generator"], "");
1565        // Registration defaults are materialized alongside the source.
1566        assert_eq!(entry["args"]["prefilter_face_size"], serde_json::json!(512));
1567
1568        let _ = std::fs::remove_dir_all(&dir);
1569    }
1570
1571    // The entry a `.hdr` add produces is one the cook accepts.
1572    #[test]
1573    fn an_hdr_entry_validates_against_the_environment_map_schema() {
1574        let _guard = crate::test_support::lock();
1575        let dir = text_test_dir(line!());
1576        let path = dir.join("studio.hdr");
1577        std::fs::write(&path, b"radiance").unwrap();
1578
1579        let entry = entry_from_path(path.to_str().unwrap()).unwrap().remove(0);
1580        concinnity_cook::validate_asset("EnvironmentMap", "studio", &entry["args"])
1581            .expect("a `.hdr` add must produce a cookable EnvironmentMap");
1582
1583        let _ = std::fs::remove_dir_all(&dir);
1584    }
1585
1586    // try_retarget_environment_map
1587
1588    // A world binds one lighting environment, so a second `.hdr` retargets the
1589    // existing map rather than appending one the runtime would ignore. Only the
1590    // source changes; the tuning the user set survives.
1591    #[test]
1592    fn try_retarget_environment_map_repoints_the_existing_map() {
1593        let mut assets = vec![serde_json::json!({
1594            "name": "env_sky",
1595            "type": "EnvironmentMap",
1596            "args": {
1597                "source": "assets/hdri/old.hdr",
1598                "generator": "",
1599                "prefilter_face_size": 1024,
1600                "prefilter_clamp": 4.0,
1601            }
1602        })];
1603        let entries = vec![serde_json::json!({
1604            "name": "studio",
1605            "type": "EnvironmentMap",
1606            "args": {"source": "assets/hdri/studio.hdr", "generator": ""}
1607        })];
1608
1609        let out = try_retarget_environment_map(&mut assets, &entries);
1610        assert_eq!(
1611            out,
1612            Some(("env_sky".to_string(), "assets/hdri/studio.hdr".to_string()))
1613        );
1614        assert_eq!(assets.len(), 1, "no second map appended");
1615        let args = assets[0]["args"].as_object().unwrap();
1616        assert_eq!(args["source"], "assets/hdri/studio.hdr");
1617        // Hand-tuned knobs survive the retarget.
1618        assert_eq!(args["prefilter_face_size"], 1024);
1619        assert_eq!(args["prefilter_clamp"], 4.0);
1620        // The name is untouched, so anything referring to it still resolves.
1621        assert_eq!(assets[0]["name"], "env_sky");
1622    }
1623
1624    // Retargeting a procedural map clears its generator: the two are mutually
1625    // exclusive and the cook rejects an entry carrying both.
1626    #[test]
1627    fn try_retarget_environment_map_clears_a_generator() {
1628        let mut assets = vec![serde_json::json!({
1629            "name": "env",
1630            "type": "EnvironmentMap",
1631            "args": {"source": "", "generator": "sky"}
1632        })];
1633        let entries = vec![serde_json::json!({
1634            "name": "dusk",
1635            "type": "EnvironmentMap",
1636            "args": {"source": "dusk.hdr", "generator": ""}
1637        })];
1638
1639        assert!(try_retarget_environment_map(&mut assets, &entries).is_some());
1640        let args = assets[0]["args"].as_object().unwrap();
1641        assert_eq!(args["source"], "dusk.hdr");
1642        assert_eq!(args["generator"], "");
1643    }
1644
1645    // The first map is the one the runtime binds, so it is the one retargeted.
1646    #[test]
1647    fn try_retarget_environment_map_takes_the_first_of_several() {
1648        let mut assets = vec![
1649            serde_json::json!({"name": "a", "type": "EnvironmentMap", "args": {"source": "a.hdr"}}),
1650            serde_json::json!({"name": "b", "type": "EnvironmentMap", "args": {"source": "b.hdr"}}),
1651        ];
1652        let entries = vec![
1653            serde_json::json!({"name": "c", "type": "EnvironmentMap", "args": {"source": "c.hdr"}}),
1654        ];
1655
1656        let (name, _) = try_retarget_environment_map(&mut assets, &entries).unwrap();
1657        assert_eq!(name, "a");
1658        assert_eq!(assets[0]["args"]["source"], "c.hdr");
1659        assert_eq!(
1660            assets[1]["args"]["source"], "b.hdr",
1661            "the rest are untouched"
1662        );
1663    }
1664
1665    // No existing map: the caller falls through to the normal append.
1666    #[test]
1667    fn try_retarget_environment_map_skips_a_world_without_one() {
1668        let mut assets = vec![serde_json::json!({
1669            "name": "lamp", "type": "PointLight", "args": {}
1670        })];
1671        let entries = vec![
1672            serde_json::json!({"name": "env", "type": "EnvironmentMap", "args": {"source": "e.hdr"}}),
1673        ];
1674        assert!(try_retarget_environment_map(&mut assets, &entries).is_none());
1675    }
1676
1677    #[test]
1678    fn try_retarget_environment_map_skips_other_types() {
1679        let mut assets = vec![serde_json::json!({
1680            "name": "env", "type": "EnvironmentMap", "args": {"source": "e.hdr"}
1681        })];
1682        let entries =
1683            vec![serde_json::json!({"name": "face", "type": "Font", "args": {"path": "face.ttf"}})];
1684        assert!(try_retarget_environment_map(&mut assets, &entries).is_none());
1685        assert_eq!(assets[0]["args"]["source"], "e.hdr");
1686    }
1687
1688    // A fan-out target (a scene, a multi-stage shader) must never retarget.
1689    #[test]
1690    fn try_retarget_environment_map_skips_multi_entry() {
1691        let mut assets = vec![serde_json::json!({
1692            "name": "env", "type": "EnvironmentMap", "args": {"source": "e.hdr"}
1693        })];
1694        let entries = vec![
1695            serde_json::json!({"name": "a", "type": "EnvironmentMap", "args": {"source": "a.hdr"}}),
1696            serde_json::json!({"name": "b", "type": "EnvironmentMap", "args": {"source": "b.hdr"}}),
1697        ];
1698        assert!(try_retarget_environment_map(&mut assets, &entries).is_none());
1699    }
1700
1701    #[test]
1702    fn markdown_with_frontmatter_becomes_story_import() {
1703        let dir = text_test_dir(line!());
1704        let path = dir.join("crossroads.md");
1705        std::fs::write(&path, "---\ntitle: T\n---\n\n# a\n\nhi\n").unwrap();
1706
1707        let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1708        assert_eq!(entries.len(), 1);
1709        let entry = &entries[0];
1710        assert_eq!(entry["type"], "StoryImport");
1711        assert_eq!(entry["name"], "crossroads");
1712        assert_eq!(entry["args"]["source"], path.to_str().unwrap());
1713        // Registration defaults are materialized alongside the source.
1714        assert_eq!(entry["args"]["title_screen"], serde_json::json!(true));
1715
1716        let _ = std::fs::remove_dir_all(&dir);
1717    }
1718
1719    #[test]
1720    fn markdown_without_frontmatter_stays_a_text_label() {
1721        let dir = text_test_dir(line!());
1722        let path = dir.join("notes.md");
1723        std::fs::write(&path, "# Notes\n\nplain markdown\n").unwrap();
1724
1725        let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1726        assert_eq!(entries.len(), 1);
1727        assert_eq!(entries[0]["type"], "TextLabel");
1728
1729        let _ = std::fs::remove_dir_all(&dir);
1730    }
1731
1732    #[test]
1733    fn text_file_over_size_cap_errors() {
1734        let dir = text_test_dir(line!());
1735        let path = dir.join("huge.txt");
1736        let blob = "a".repeat(TEXT_LABEL_MAX_BYTES + 1);
1737        std::fs::write(&path, blob).unwrap();
1738
1739        let err = entry_from_path(path.to_str().unwrap())
1740            .expect_err("oversized text file must fail rather than silently truncate");
1741        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1742        assert!(err.to_string().contains("capped"));
1743
1744        let _ = std::fs::remove_dir_all(&dir);
1745    }
1746
1747    #[test]
1748    fn scaffold_to_inject_empty_for_missing_world_with_text_target() {
1749        // Text targets bootstrap a missing world via the TextLabel itself:
1750        // no separate scaffold needed, and the missing file must not error.
1751        let dir = text_test_dir(line!());
1752        let world = dir.join("world.jsonl");
1753
1754        let scaffold = scaffold_to_inject(world.to_str().unwrap(), "notes.txt", None).unwrap();
1755        assert!(scaffold.is_empty(), "text target should emit no scaffold");
1756
1757        let _ = std::fs::remove_dir_all(&dir);
1758    }
1759
1760    #[test]
1761    fn scaffold_to_inject_empty_for_renderer_less_world_with_text_target() {
1762        let dir = text_test_dir(line!());
1763        let world = dir.join("world.jsonl");
1764        std::fs::write(&world, r#"{"name":"tex","type":"Texture","args":{}}"#).unwrap();
1765
1766        let scaffold = scaffold_to_inject(world.to_str().unwrap(), "notes.md", None).unwrap();
1767        assert!(
1768            scaffold.is_empty(),
1769            "text target never injects GLB scaffold"
1770        );
1771
1772        let _ = std::fs::remove_dir_all(&dir);
1773    }
1774
1775    // try_refresh_text_label
1776
1777    #[test]
1778    fn try_refresh_text_label_updates_content_in_place() {
1779        // Existing TextLabel was hand-edited: y, color, scale, centered all
1780        // diverged from defaults. A refresh should touch only `content`.
1781        let mut assets = vec![serde_json::json!({
1782            "name": "greeting",
1783            "type": "TextLabel",
1784            "args": {
1785                "content": "Old text",
1786                "font": "Questrial-Regular",
1787                "x": 10.0,
1788                "y": 200.0,
1789                "color": [0.2, 0.8, 0.4],
1790                "scale": 1.5,
1791                "centered": false,
1792                "background": [0.0, 0.0, 0.0, 0.0],
1793                "padding": 0.0,
1794                "visible": true,
1795                "screen": null,
1796            }
1797        })];
1798        let entries = vec![serde_json::json!({
1799            "name": "greeting",
1800            "type": "TextLabel",
1801            "args": {
1802                "content": "New text",
1803                "centered": true,
1804            }
1805        })];
1806
1807        let refreshed = try_refresh_text_label(&mut assets, &entries);
1808        assert_eq!(refreshed.as_deref(), Some("greeting"));
1809        let args = assets[0]["args"].as_object().unwrap();
1810        assert_eq!(args["content"], "New text");
1811        // Hand edits survive: only `content` was copied across.
1812        assert_eq!(args["y"], 200.0);
1813        assert_eq!(args["color"], serde_json::json!([0.2, 0.8, 0.4]));
1814        assert_eq!(args["scale"], 1.5);
1815        assert_eq!(args["centered"], false);
1816    }
1817
1818    #[test]
1819    fn try_refresh_text_label_skips_non_textlabel_new_entry() {
1820        let mut assets = vec![serde_json::json!({
1821            "name": "thing",
1822            "type": "TextLabel",
1823            "args": {"content": "old"}
1824        })];
1825        let entries = vec![serde_json::json!({
1826            "name": "thing",
1827            "type": "Font",
1828            "args": {"path": "x.ttf"}
1829        })];
1830        assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1831    }
1832
1833    #[test]
1834    fn try_refresh_text_label_skips_when_existing_is_different_type() {
1835        let mut assets = vec![serde_json::json!({
1836            "name": "thing",
1837            "type": "Font",
1838            "args": {"path": "x.ttf"}
1839        })];
1840        let entries = vec![serde_json::json!({
1841            "name": "thing",
1842            "type": "TextLabel",
1843            "args": {"content": "hi"}
1844        })];
1845        // Same name, different existing type: refresh shouldn't fire; caller
1846        // falls through to the duplicate-name error.
1847        assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1848    }
1849
1850    #[test]
1851    fn try_refresh_text_label_skips_when_name_misses() {
1852        let mut assets = vec![serde_json::json!({
1853            "name": "greeting",
1854            "type": "TextLabel",
1855            "args": {"content": "old"}
1856        })];
1857        let entries = vec![serde_json::json!({
1858            "name": "caption",
1859            "type": "TextLabel",
1860            "args": {"content": "new"}
1861        })];
1862        assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1863    }
1864
1865    #[test]
1866    fn try_refresh_text_label_skips_multi_entry() {
1867        // A fan-out target (e.g. a metal shader producing vert+frag) must
1868        // never trigger the in-place refresh path.
1869        let mut assets = vec![serde_json::json!({
1870            "name": "label",
1871            "type": "TextLabel",
1872            "args": {"content": "old"}
1873        })];
1874        let entries = vec![
1875            serde_json::json!({"name": "label", "type": "TextLabel", "args": {"content": "new"}}),
1876            serde_json::json!({"name": "other", "type": "TextLabel", "args": {"content": "other"}}),
1877        ];
1878        assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1879    }
1880
1881    #[test]
1882    fn scaffold_to_inject_rejects_template_with_text_target() {
1883        let dir = text_test_dir(line!());
1884        let world = dir.join("world.jsonl");
1885
1886        let name = concinnity_world::template::TEMPLATES[0].name;
1887        let err = scaffold_to_inject(world.to_str().unwrap(), "notes.txt", Some(name))
1888            .expect_err("--template should only apply to scene targets");
1889        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1890
1891        let _ = std::fs::remove_dir_all(&dir);
1892    }
1893
1894    // entry_from_inline_json
1895
1896    #[test]
1897    fn inline_json_must_be_an_object() {
1898        let err = entry_from_inline_json("[1, 2]").unwrap_err();
1899        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1900        assert!(err.to_string().contains("must be an object"), "got: {err}");
1901    }
1902
1903    #[test]
1904    fn inline_json_requires_a_type_field() {
1905        let err = entry_from_inline_json(r#"{"name":"thing"}"#).unwrap_err();
1906        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1907        assert!(err.to_string().contains("`type` field"), "got: {err}");
1908    }
1909
1910    #[test]
1911    fn inline_json_rejects_malformed_text() {
1912        let err = entry_from_inline_json("{ nope").unwrap_err();
1913        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1914    }
1915
1916    #[test]
1917    fn inline_json_rejects_build_config() {
1918        let err = entry_from_inline_json(r#"{"type":"BuildConfig"}"#).unwrap_err();
1919        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1920        // The underscore spelling is caught by the same normalisation.
1921        let err = entry_from_inline_json(r#"{"type":"build_config"}"#).unwrap_err();
1922        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1923    }
1924
1925    #[test]
1926    fn inline_json_defaults_the_name_to_the_lowercased_type() {
1927        let entry = entry_from_inline_json(r#"{"type":"Window"}"#).unwrap();
1928        assert_eq!(entry["name"], "window");
1929        assert_eq!(entry["type"], "Window");
1930        // Default args are materialized as an object.
1931        assert!(entry["args"].is_object());
1932    }
1933
1934    #[test]
1935    fn inline_json_keeps_an_explicit_name() {
1936        let entry = entry_from_inline_json(r#"{"type":"Window","name":"main"}"#).unwrap();
1937        assert_eq!(entry["name"], "main");
1938    }
1939
1940    // entry_from_type_name
1941
1942    #[test]
1943    fn type_name_builds_a_default_entry() {
1944        let entry = entry_from_type_name("Window").unwrap();
1945        assert_eq!(entry["name"], "window");
1946        assert_eq!(entry["type"], "Window");
1947        assert!(entry["args"].is_object());
1948    }
1949
1950    #[test]
1951    fn type_name_rejects_build_config() {
1952        let err = entry_from_type_name("BuildConfig").unwrap_err();
1953        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1954    }
1955
1956    #[test]
1957    fn type_name_rejects_an_unknown_type() {
1958        assert!(entry_from_type_name("NotARealAssetType").is_err());
1959    }
1960
1961    // entry_from_json_file
1962
1963    #[test]
1964    fn json_file_requires_a_type_field() {
1965        let dir = text_test_dir(line!());
1966        let path = dir.join("thing.json");
1967        std::fs::write(&path, r#"{"name":"thing"}"#).unwrap();
1968
1969        let err = entry_from_json_file(&path, "thing").unwrap_err();
1970        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1971        assert!(err.to_string().contains("no `type` field"), "got: {err}");
1972
1973        let _ = std::fs::remove_dir_all(&dir);
1974    }
1975
1976    #[test]
1977    fn json_file_rejects_build_config() {
1978        let dir = text_test_dir(line!());
1979        let path = dir.join("cfg.json");
1980        std::fs::write(&path, r#"{"type":"BuildConfig"}"#).unwrap();
1981
1982        let err = entry_from_json_file(&path, "cfg").unwrap_err();
1983        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1984
1985        let _ = std::fs::remove_dir_all(&dir);
1986    }
1987
1988    #[test]
1989    fn json_file_uses_the_stem_when_unnamed() {
1990        let dir = text_test_dir(line!());
1991        let path = dir.join("main_window.json");
1992        std::fs::write(&path, r#"{"type":"Window","args":{}}"#).unwrap();
1993
1994        let entry = entry_from_json_file(&path, "main_window").unwrap();
1995        assert_eq!(entry["name"], "main_window");
1996        assert_eq!(entry["type"], "Window");
1997
1998        let _ = std::fs::remove_dir_all(&dir);
1999    }
2000
2001    #[test]
2002    fn json_file_read_and_parse_failures_are_reported() {
2003        let dir = text_test_dir(line!());
2004        // Missing file.
2005        let missing = dir.join("missing.json");
2006        assert!(entry_from_json_file(&missing, "missing").is_err());
2007        // Unparseable content.
2008        let junk = dir.join("junk.json");
2009        std::fs::write(&junk, "{ nope").unwrap();
2010        let err = entry_from_json_file(&junk, "junk").unwrap_err();
2011        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2012
2013        let _ = std::fs::remove_dir_all(&dir);
2014    }
2015
2016    // resolve_add_target
2017
2018    #[test]
2019    fn resolve_add_target_dispatches_type_names_and_inline_json() {
2020        let entries = resolve_add_target("Window").unwrap();
2021        assert_eq!(entries.len(), 1);
2022        assert_eq!(entries[0]["type"], "Window");
2023
2024        let entries = resolve_add_target(r#"{"type":"Window","name":"main"}"#).unwrap();
2025        assert_eq!(entries.len(), 1);
2026        assert_eq!(entries[0]["name"], "main");
2027    }
2028
2029    #[test]
2030    fn resolve_add_target_reports_an_unresolvable_target() {
2031        let err = resolve_add_target("DefinitelyNotAnAssetOrFile").unwrap_err();
2032        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
2033        assert!(err.to_string().contains("could not resolve"), "got: {err}");
2034    }
2035
2036    // Every extension the picker advertises is one `entry_from_path` actually
2037    // dispatches: each resolves, or fails for a content reason -- never with
2038    // "unknown extension". Guards the two lists against drifting apart.
2039    #[test]
2040    fn import_extension_groups_track_the_dispatch() {
2041        let dir = tempfile::tempdir().unwrap();
2042        for (_, exts) in IMPORT_EXTENSION_GROUPS {
2043            for ext in *exts {
2044                let path = dir.path().join(format!("probe.{ext}"));
2045                // Valid-enough content for the arms that read their file.
2046                std::fs::write(&path, b"{}").unwrap();
2047                let path_str = path.to_string_lossy();
2048                if let Err(e) = entry_from_path(&path_str) {
2049                    assert!(
2050                        !e.to_string().contains("unknown extension"),
2051                        ".{ext} is advertised by the picker but not dispatched: {e}"
2052                    );
2053                }
2054            }
2055        }
2056    }
2057
2058    // A picker group is only useful if the dialog can build a filter from it.
2059    #[test]
2060    fn import_extension_groups_are_non_empty_and_unique() {
2061        let mut seen = std::collections::HashSet::new();
2062        for (name, exts) in IMPORT_EXTENSION_GROUPS {
2063            assert!(!exts.is_empty(), "{name} has no extensions");
2064            for ext in *exts {
2065                assert!(seen.insert(*ext), "'{ext}' listed in two groups");
2066                assert!(
2067                    !ext.starts_with('.'),
2068                    "'{ext}' should be bare (rfd adds the dot)"
2069                );
2070            }
2071        }
2072    }
2073
2074    // import_entry
2075
2076    #[test]
2077    fn import_entry_sets_the_source_on_registration_defaults() {
2078        let entry = import_entry("SceneImport", "bistro", "scenes/bistro.fbx").unwrap();
2079        assert_eq!(entry["name"], "bistro");
2080        assert_eq!(entry["type"], "SceneImport");
2081        assert_eq!(entry["args"]["source"], "scenes/bistro.fbx");
2082    }
2083
2084    #[test]
2085    fn import_entry_rejects_an_unknown_type() {
2086        assert!(import_entry("NotARealAssetType", "x", "x.glb").is_err());
2087    }
2088
2089    // scene / panorama split
2090    //
2091    // Which side of the split a file lands on is
2092    // `concinnity_cook::panorama`'s call and is covered against real panorama
2093    // and multi-mesh fixtures there; these pin what each answer produces here.
2094
2095    #[test]
2096    fn a_glb_that_is_not_a_panorama_imports_as_geometry() {
2097        let dir = text_test_dir(line!());
2098        let path = dir.join("scene.glb");
2099        std::fs::write(&path, b"not a panorama").unwrap();
2100
2101        let entries = entry_from_path(path.to_str().unwrap()).unwrap();
2102        assert_eq!(entries.len(), 1);
2103        assert_eq!(entries[0]["type"], "SceneImport");
2104        assert_eq!(entries[0]["args"]["source"], path.to_str().unwrap());
2105    }
2106
2107    #[test]
2108    fn a_panorama_becomes_an_environment_map_naming_its_source() {
2109        let entry = environment_map_entry("galaxy", "assets/hdri/galaxy.glb").unwrap();
2110        assert_eq!(entry["name"], "galaxy");
2111        assert_eq!(entry["type"], "EnvironmentMap");
2112        assert_eq!(entry["args"]["source"], "assets/hdri/galaxy.glb");
2113    }
2114
2115    #[test]
2116    fn an_hdr_still_becomes_an_environment_map() {
2117        let dir = text_test_dir(line!());
2118        let path = dir.join("studio.hdr");
2119        std::fs::write(&path, b"#?RADIANCE\n").unwrap();
2120
2121        let entries = entry_from_path(path.to_str().unwrap()).unwrap();
2122        assert_eq!(entries[0]["type"], "EnvironmentMap");
2123    }
2124
2125    #[test]
2126    fn an_fbx_never_takes_the_panorama_branch() {
2127        let entries = entry_from_path("scenes/bistro.fbx").unwrap();
2128        assert_eq!(entries[0]["type"], "SceneImport");
2129    }
2130
2131    // ensure_world_file_exists
2132
2133    #[test]
2134    fn ensure_world_file_exists_creates_parents_and_is_idempotent() {
2135        let dir = text_test_dir(line!());
2136        let world = dir.join("nested").join("deeper").join("world.jsonl");
2137
2138        ensure_world_file_exists(world.to_str().unwrap()).unwrap();
2139        assert!(world.exists());
2140        assert_eq!(std::fs::read_to_string(&world).unwrap(), "");
2141
2142        // A second call leaves the existing file alone.
2143        std::fs::write(&world, "content").unwrap();
2144        ensure_world_file_exists(world.to_str().unwrap()).unwrap();
2145        assert_eq!(std::fs::read_to_string(&world).unwrap(), "content");
2146
2147        let _ = std::fs::remove_dir_all(&dir);
2148    }
2149
2150    // is_path_like
2151
2152    #[test]
2153    fn is_path_like_recognises_separators_prefixes_and_dotted_files() {
2154        assert!(is_path_like("models/scene.glb"));
2155        assert!(is_path_like("models\\scene.glb"));
2156        assert!(is_path_like("./scene.glb"));
2157        assert!(is_path_like("~/scene.glb"));
2158        assert!(is_path_like("scene.glb"));
2159        // A bare known type name is not a path, even without a dot.
2160        assert!(!is_path_like("Window"));
2161    }
2162}