1use 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
23pub 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 if let Some(refreshed) = try_refresh_text_label(assets, &entries) {
69 tracing::info!("refreshed TextLabel '{}' content", refreshed);
70 return Ok(());
71 }
72
73 if let Some((retargeted, source)) = try_retarget_environment_map(assets, &entries) {
77 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 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
131pub(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
150fn 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
185pub(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
223fn 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 Ok(template_entries.unwrap_or_default())
274 }
275 }
276}
277
278fn 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
301fn available_templates() -> String {
303 concinnity_cook::authoring::template::TEMPLATES
304 .iter()
305 .map(|t| t.name)
306 .collect::<Vec<_>>()
307 .join(", ")
308}
309
310fn 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
321fn 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
341fn 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
358pub(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 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 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
436fn 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
457fn 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
482pub(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
499pub(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 let stem = concinnity_cook::authoring::world::asset_name_from_path(path_str);
514
515 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 "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" => {
544 let (vert, frag, pair_stem) = glsl_pair(path, path_str, &stem)?;
545 shader_pair_entry(&pair_stem, &vert, &frag)
546 }
547
548 "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" => {
556 let source = read_source_file(path_str)?;
557 shader_entries_from_stages(path_str, &stem, "wgsl", &detect_wgsl_stages(&source))
558 }
559
560 "ttf" | "otf" => Ok(vec![validated_entry(
562 &stem,
563 "Font",
564 serde_json::json!({ "path": path_str }),
565 )?]),
566
567 "gguf" => Ok(vec![validated_entry(
569 &base_name,
570 "LLM",
571 serde_json::json!({ "lib_path": "", "model_path": path_str }),
572 )?]),
573
574 "ogg" | "wav" | "mp3" | "flac" => Ok(vec![validated_entry(
579 &stem,
580 "AudioClip",
581 serde_json::json!({ "source": path_str }),
582 )?]),
583
584 "hdr" => Ok(vec![environment_map_entry(&stem, path_str)?]),
590
591 "ktx2" => Ok(vec![validated_entry(
595 &base_name,
596 "Texture",
597 serde_json::json!({ "source": path_str }),
598 )?]),
599
600 "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 "txt" => Ok(vec![text_label_entry(&stem, path_str)?]),
618
619 "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 "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
651fn 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
674fn 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
695fn 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
712fn 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
736fn 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
792pub(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
807pub(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
833const TEXT_LABEL_MAX_BYTES: usize = 64 * 1024;
837
838fn 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
883fn 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
1002pub(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 #[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 let src = " vertex VertexOut vert_main() {}\n fragment float4 frag_main() {}";
1108 assert_eq!(detect_metal_stages(src), vec!["vertex", "fragment"]);
1109 }
1110
1111 #[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 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 std::fs::remove_file(&frag).unwrap();
1169 assert!(glsl_pair(&vert, vert.to_str().unwrap(), "foo_vert").is_err());
1170 }
1171
1172 #[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 assert_eq!(stem, "JetBrainsMono-Regular");
1184 assert!(!stem.contains("ttf"));
1185 }
1186
1187 #[test]
1190 fn unknown_extension_errors() {
1191 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 #[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 #[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 #[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 #[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 #[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 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 #[test]
1324 fn scaffold_to_inject_writes_nothing_without_a_template() {
1325 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 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 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "Logger", None).unwrap();
1389 assert!(scaffold.is_empty());
1390 }
1391
1392 #[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 #[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 let dir = concinnity_testing::TempTree::new();
1459 let world = dir.join("world.jsonl");
1460 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 #[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 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 assert_eq!(entry["args"]["generator"], "");
1533 assert_eq!(entry["args"]["prefilter_face_size"], serde_json::json!(512));
1535 }
1536
1537 #[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 #[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 assert_eq!(args["prefilter_face_size"], 1024);
1588 assert_eq!(args["prefilter_clamp"], 4.0);
1589 assert_eq!(assets[0]["name"], "env_sky");
1591 }
1592
1593 #[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 #[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 #[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 #[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 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 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 #[test]
1737 fn try_refresh_text_label_updates_content_in_place() {
1738 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 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 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 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 #[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 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 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 #[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 #[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 let missing = dir.join("missing.json");
1957 assert!(entry_from_json_file(&missing, "missing").is_err());
1958 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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 assert!(!is_path_like("Window"));
2108 }
2109}