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