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(&tmp_path, crate::cook_platform()) {
117 Ok(()) => std::fs::rename(&tmp_path, world_path).inspect_err(|_e| {
118 let _ = std::fs::remove_file(&tmp_path);
119 }),
120 Err(e) => {
121 let _ = std::fs::remove_file(&tmp_path);
122 Err(e)
123 }
124 }
125}
126
127pub(crate) fn apply_name_override(entries: &mut [serde_json::Value], name: &str) {
132 if let [only] = entries {
133 only["name"] = serde_json::Value::String(name.to_string());
134 return;
135 }
136 for entry in entries {
137 let existing = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
138 let suffix = existing
139 .rsplit_once('_')
140 .map(|(_, s)| format!("_{s}"))
141 .unwrap_or_default();
142 entry["name"] = serde_json::Value::String(format!("{name}{suffix}"));
143 }
144}
145
146fn try_refresh_text_label(
159 assets: &mut [serde_json::Value],
160 entries: &[serde_json::Value],
161) -> Option<String> {
162 if entries.len() != 1 {
163 return None;
164 }
165 let new = &entries[0];
166 if new.get("type").and_then(|v| v.as_str()) != Some("TextLabel") {
167 return None;
168 }
169 let new_name = new.get("name").and_then(|v| v.as_str())?.to_string();
170 let new_content = new.get("args").and_then(|a| a.get("content")).cloned()?;
171
172 let existing = assets.iter_mut().find(|a| {
173 a.get("name").and_then(|v| v.as_str()) == Some(new_name.as_str())
174 && a.get("type").and_then(|v| v.as_str()) == Some("TextLabel")
175 })?;
176 let args = existing.get_mut("args")?.as_object_mut()?;
177 args.insert("content".to_string(), new_content);
178 Some(new_name)
179}
180
181pub(crate) fn try_retarget_environment_map(
191 assets: &mut [serde_json::Value],
192 entries: &[serde_json::Value],
193) -> Option<(String, String)> {
194 if entries.len() != 1 {
195 return None;
196 }
197 let new = &entries[0];
198 if new.get("type").and_then(|v| v.as_str()) != Some("EnvironmentMap") {
199 return None;
200 }
201 let new_source = new.get("args")?.get("source")?.as_str()?.to_string();
202
203 let existing = assets
204 .iter_mut()
205 .find(|a| a.get("type").and_then(|v| v.as_str()) == Some("EnvironmentMap"))?;
206 let name = existing.get("name").and_then(|v| v.as_str())?.to_string();
207 let args = existing.get_mut("args")?.as_object_mut()?;
208 args.insert(
209 "source".to_string(),
210 serde_json::Value::String(new_source.clone()),
211 );
212 args.insert(
213 "generator".to_string(),
214 serde_json::Value::String(String::new()),
215 );
216 Some((name, new_source))
217}
218
219fn scaffold_to_inject(
234 world_path: &str,
235 target: &str,
236 template: Option<&str>,
237) -> std::io::Result<Vec<serde_json::Value>> {
238 let template_entries = resolve_template(template)?;
239
240 let world_exists = std::path::Path::new(world_path).exists();
241 let bootstrap = target_bootstrap_kind(target);
242
243 if !world_exists && matches!(bootstrap, BootstrapKind::None) {
244 return Err(std::io::Error::new(
245 std::io::ErrorKind::NotFound,
246 format!(
247 "no world found at '{}': create one with `cn fetch-world` or `cn new`",
248 world_path
249 ),
250 ));
251 }
252
253 if template_entries.is_some() && !matches!(bootstrap, BootstrapKind::Scene) {
254 return Err(std::io::Error::new(
255 std::io::ErrorKind::InvalidInput,
256 "--template only applies to 3D scene targets (.glb)",
257 ));
258 }
259
260 match bootstrap {
261 BootstrapKind::None | BootstrapKind::Text => Ok(Vec::new()),
262 BootstrapKind::Scene => {
263 if world_exists && has_renderer_trigger(world_path)? {
264 return Ok(Vec::new());
265 }
266 Ok(template_entries.unwrap_or_default())
270 }
271 }
272}
273
274fn resolve_template(template: Option<&str>) -> std::io::Result<Option<Vec<serde_json::Value>>> {
280 let Some(name) = template else {
281 return Ok(None);
282 };
283 match concinnity_cook::authoring::template::by_name(name) {
284 Some(t) => Ok(Some(
285 crate::authoring::template_spec::world_template_entries(t),
286 )),
287 None => Err(std::io::Error::new(
288 std::io::ErrorKind::InvalidInput,
289 format!(
290 "unknown template '{name}'; available: {}",
291 available_templates()
292 ),
293 )),
294 }
295}
296
297fn available_templates() -> String {
299 concinnity_cook::authoring::template::TEMPLATES
300 .iter()
301 .map(|t| t.name)
302 .collect::<Vec<_>>()
303 .join(", ")
304}
305
306fn has_renderer_trigger(world_path: &str) -> std::io::Result<bool> {
313 let content = std::fs::read_to_string(world_path)?;
314 Ok(jsonl_has_renderer_trigger(&content))
315}
316
317fn jsonl_has_renderer_trigger(content: &str) -> bool {
319 for line in content.lines() {
320 let line = line.trim();
321 if line.is_empty() {
322 continue;
323 }
324 let value: serde_json::Value = match serde_json::from_str(line) {
325 Ok(v) => v,
326 Err(_) => continue,
327 };
328 if let Some(t) = value.get("type").and_then(|v| v.as_str())
329 && concinnity_cook::authoring::registry::type_renders(t)
330 {
331 return true;
332 }
333 }
334 false
335}
336
337fn ensure_world_file_exists(world_path: &str) -> std::io::Result<()> {
341 if std::path::Path::new(world_path).exists() {
342 return Ok(());
343 }
344 if let Some(parent) = std::path::Path::new(world_path).parent()
345 && !parent.as_os_str().is_empty()
346 {
347 std::fs::create_dir_all(parent)?;
348 }
349 std::fs::write(world_path, "")?;
350 tracing::info!("created empty world file at {}", world_path);
351 Ok(())
352}
353
354pub(crate) enum BootstrapKind {
360 None,
361 Scene,
362 Text,
363}
364
365pub(crate) fn target_bootstrap_kind(target: &str) -> BootstrapKind {
366 let ext = std::path::Path::new(target)
367 .extension()
368 .and_then(|e| e.to_str())
369 .map(|e| e.to_ascii_lowercase());
370 match ext.as_deref() {
371 Some("glb") | Some("gltf") | Some("fbx") => BootstrapKind::Scene,
372 Some("txt") | Some("md") => BootstrapKind::Text,
373 _ => BootstrapKind::None,
374 }
375}
376
377pub(crate) fn is_path_like(s: &str) -> bool {
378 if s.contains('/') || s.contains('\\') {
379 return true;
380 }
381 if s.starts_with('.') || s.starts_with('~') {
382 return true;
383 }
384 if s.contains('.') {
386 return RegisteredType::parse(s).is_none();
387 }
388 false
389}
390
391fn validated_entry(
392 name: &str,
393 asset_type: &str,
394 args: serde_json::Value,
395) -> std::io::Result<serde_json::Value> {
396 if let Some(rt) = RegisteredType::parse(asset_type).filter(|t| t.is_resource()) {
399 let mut resolved = rt
400 .registration()
401 .default_args
402 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
403 if let (serde_json::Value::Object(base), serde_json::Value::Object(supplied)) =
404 (&mut resolved, &args)
405 {
406 for (k, v) in supplied {
407 base.insert(k.clone(), v.clone());
408 }
409 }
410 return Ok(serde_json::json!({
411 "name": name,
412 "type": asset_type,
413 "args": resolved,
414 }));
415 }
416
417 let req = AssetRequest {
418 asset_type: asset_type.to_string(),
419 args: Some(args.clone()),
420 };
421 create_asset_def(&req, crate::cook_platform())
422 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
423 let resolved_args = normalized_args_value(asset_type, &args);
424
425 Ok(serde_json::json!({
426 "name": name,
427 "type": asset_type,
428 "args": resolved_args,
429 }))
430}
431
432fn normalized_args_value(asset_type: &str, args: &serde_json::Value) -> serde_json::Value {
437 let empty = || serde_json::Value::Object(Default::default());
438 let Some(ct) = RegisteredType::parse(asset_type) else {
439 return empty();
440 };
441 let mut merged = ct.registration().default_args.unwrap_or_else(empty);
442 if let (serde_json::Value::Object(base), serde_json::Value::Object(supplied)) =
443 (&mut merged, args)
444 {
445 for (k, v) in supplied {
446 base.insert(k.clone(), v.clone());
447 }
448 }
449 ct.normalized_args(&merged, crate::cook_platform())
450 .unwrap_or_else(|_| empty())
451}
452
453fn import_entry(asset_type: &str, name: &str, source: &str) -> std::io::Result<serde_json::Value> {
459 let reg = RegisteredType::parse(asset_type)
460 .ok_or_else(|| std::io::Error::other(format!("{} asset type is unavailable", asset_type)))?
461 .registration();
462 let mut args = reg
463 .default_args
464 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
465 if let serde_json::Value::Object(map) = &mut args {
466 map.insert(
467 "source".to_string(),
468 serde_json::Value::String(source.to_string()),
469 );
470 }
471 Ok(serde_json::json!({
472 "name": name,
473 "type": asset_type,
474 "args": args,
475 }))
476}
477
478pub(crate) const IMPORT_EXTENSION_GROUPS: &[(&str, &[&str])] = &[
484 ("Scenes", &["glb", "gltf", "fbx"]),
485 ("Stories & text", &["md", "txt"]),
486 ("Images", &["png", "jpg", "jpeg", "bmp", "tga", "gif"]),
487 ("Textures", &["ktx2"]),
488 ("Environment maps", &["hdr"]),
489 ("Audio", &["ogg", "wav", "mp3", "flac"]),
490 ("Fonts", &["ttf", "otf"]),
491 ("Shaders", &["vert", "frag", "glsl", "metal", "wgsl"]),
492 ("Models & data", &["obj", "mtl", "json", "gguf"]),
493];
494
495pub(crate) fn entry_from_path(path_str: &str) -> std::io::Result<Vec<serde_json::Value>> {
499 let path = std::path::Path::new(path_str);
500
501 let ext = path
502 .extension()
503 .and_then(|e| e.to_str())
504 .map(|e| e.to_lowercase())
505 .unwrap_or_default();
506
507 let stem = concinnity_cook::authoring::world::asset_name_from_path(path_str);
510
511 let base_name = if ext == "json" {
513 stem.clone()
514 } else {
515 path.file_name()
516 .and_then(|s| s.to_str())
517 .map(|s| s.replace('.', "_"))
518 .unwrap_or_else(|| path_str.to_string())
519 };
520
521 if ext == "json" {
522 return entry_from_json_file(path, &base_name).map(|e| vec![e]);
523 }
524
525 match ext.as_str() {
526 "vert" => {
529 let frag = sibling_path(path, "frag")?;
530 shader_pair_entry(&stem, path_str, &frag)
531 }
532 "frag" => {
533 let vert = sibling_path(path, "vert")?;
534 shader_pair_entry(&stem, &vert, path_str)
535 }
536
537 "glsl" => {
540 let (vert, frag, pair_stem) = glsl_pair(path, path_str, &stem)?;
541 shader_pair_entry(&pair_stem, &vert, &frag)
542 }
543
544 "metal" => {
546 let source = read_source_file(path_str)?;
547 shader_entries_from_stages(path_str, &stem, "metal", &detect_metal_stages(&source))
548 }
549
550 "wgsl" => {
552 let source = read_source_file(path_str)?;
553 shader_entries_from_stages(path_str, &stem, "wgsl", &detect_wgsl_stages(&source))
554 }
555
556 "ttf" | "otf" => Ok(vec![validated_entry(
558 &stem,
559 "Font",
560 serde_json::json!({ "path": path_str }),
561 )?]),
562
563 "gguf" => Ok(vec![validated_entry(
565 &base_name,
566 "LLM",
567 serde_json::json!({ "lib_path": "", "model_path": path_str }),
568 )?]),
569
570 "ogg" | "wav" | "mp3" | "flac" => Ok(vec![validated_entry(
575 &stem,
576 "AudioClip",
577 serde_json::json!({ "source": path_str }),
578 )?]),
579
580 "hdr" => Ok(vec![environment_map_entry(&stem, path_str)?]),
586
587 "ktx2" => Ok(vec![validated_entry(
591 &base_name,
592 "Texture",
593 serde_json::json!({ "source": path_str }),
594 )?]),
595
596 "obj" | "mtl" | "png" | "jpg" | "jpeg" | "bmp" | "tga" | "gif" => {
598 Ok(vec![validated_entry(
599 &base_name,
600 "File",
601 serde_json::json!({ "path": path_str, "kind": ext.as_str() }),
602 )?])
603 }
604
605 "txt" => Ok(vec![text_label_entry(&stem, path_str)?]),
614
615 "md" => {
619 if md_has_frontmatter(path_str)? {
620 Ok(vec![import_entry("StoryImport", &stem, path_str)?])
621 } else {
622 Ok(vec![text_label_entry(&stem, path_str)?])
623 }
624 }
625
626 "glb" | "gltf" => Ok(vec![scene_or_panorama_entry(&stem, path_str)?]),
634 "fbx" => Ok(vec![import_entry("SceneImport", &stem, path_str)?]),
635
636 other => Err(std::io::Error::new(
637 std::io::ErrorKind::InvalidInput,
638 format!(
639 "unknown extension '.{}'; pass a type name instead \
640 (e.g. `concinnity add Logger`) or edit {} directly",
641 other, WORLD_JSONL
642 ),
643 )),
644 }
645}
646
647fn scene_or_panorama_entry(stem: &str, path_str: &str) -> std::io::Result<serde_json::Value> {
651 if concinnity_cook::import::panorama::file_is_panorama_sphere(path_str) {
652 tracing::info!(
653 "'{}' is a panorama sphere: importing it as an EnvironmentMap (sky \
654 and image-based lighting) rather than scene geometry",
655 path_str
656 );
657 return environment_map_entry(stem, path_str);
658 }
659 import_entry("SceneImport", stem, path_str)
660}
661
662fn environment_map_entry(stem: &str, path_str: &str) -> std::io::Result<serde_json::Value> {
663 validated_entry(
664 stem,
665 "EnvironmentMap",
666 serde_json::json!({ "source": path_str }),
667 )
668}
669
670fn shader_entries_from_stages(
673 path_str: &str,
674 stem: &str,
675 ext: &str,
676 stages: &[&str],
677) -> std::io::Result<Vec<serde_json::Value>> {
678 if !(stages.contains(&"vertex") && stages.contains(&"fragment")) {
679 return Err(std::io::Error::new(
680 std::io::ErrorKind::InvalidInput,
681 format!(
682 "a Shader needs both a vertex and a fragment stage, but the .{ext} \
683 source declares only {stages:?}. Add the missing stage function, or \
684 declare a Shader entry in world.jsonl with per-stage sources"
685 ),
686 ));
687 }
688 shader_pair_entry(stem, path_str, path_str)
689}
690
691fn shader_pair_entry(
694 stem: &str,
695 vert_path: &str,
696 frag_path: &str,
697) -> std::io::Result<Vec<serde_json::Value>> {
698 Ok(vec![validated_entry(
699 &format!("{stem}_shader"),
700 "Shader",
701 serde_json::json!({
702 "vertex": { "source": vert_path },
703 "fragment": { "source": frag_path },
704 }),
705 )?])
706}
707
708fn sibling_path(path: &std::path::Path, sibling_ext: &str) -> std::io::Result<String> {
712 let sibling = path.with_extension(sibling_ext);
713 if !sibling.exists() {
714 return Err(std::io::Error::new(
715 std::io::ErrorKind::NotFound,
716 format!(
717 "a Shader needs both stages: expected the {} stage next to {} \
718 (looked for {})",
719 if sibling_ext == "vert" {
720 "vertex"
721 } else {
722 "fragment"
723 },
724 path.display(),
725 sibling.display(),
726 ),
727 ));
728 }
729 Ok(sibling.to_string_lossy().into_owned())
730}
731
732fn glsl_pair(
737 path: &std::path::Path,
738 path_str: &str,
739 stem: &str,
740) -> std::io::Result<(String, String, String)> {
741 let (this_marker, other_marker) = if stem.contains("fragment") {
742 ("fragment", "vertex")
743 } else if stem.contains("frag") {
744 ("frag", "vert")
745 } else if stem.contains("vertex") {
746 ("vertex", "fragment")
747 } else if stem.contains("vert") {
748 ("vert", "frag")
749 } else {
750 return Err(std::io::Error::new(
751 std::io::ErrorKind::InvalidInput,
752 format!(
753 "cannot infer the shader stage of {path_str}: name the files with \
754 vert/frag markers (foo_vert.glsl + foo_frag.glsl), or declare a \
755 Shader entry in world.jsonl with per-stage sources"
756 ),
757 ));
758 };
759 let file_name = path
760 .file_name()
761 .and_then(|s| s.to_str())
762 .unwrap_or(path_str);
763 let counterpart = path.with_file_name(file_name.replace(this_marker, other_marker));
764 if !counterpart.exists() {
765 return Err(std::io::Error::new(
766 std::io::ErrorKind::NotFound,
767 format!(
768 "a Shader needs both stages: expected the {other_marker} counterpart \
769 of {path_str} (looked for {})",
770 counterpart.display()
771 ),
772 ));
773 }
774 let counterpart = counterpart.to_string_lossy().into_owned();
775 let this = path_str.to_string();
776 let pair_stem = stem
777 .replace(this_marker, "")
778 .trim_matches('_')
779 .replace("__", "_");
780 let (vert, frag) = if this_marker.starts_with('v') {
781 (this, counterpart)
782 } else {
783 (counterpart, this)
784 };
785 Ok((vert, frag, pair_stem))
786}
787
788pub(crate) fn detect_metal_stages(source: &str) -> Vec<&'static str> {
792 let has_vertex = source.lines().any(|l| {
793 let t = l.trim_start();
794 t.starts_with("vertex ") || t.starts_with("vertex\t")
795 });
796 let has_fragment = source.lines().any(|l| {
797 let t = l.trim_start();
798 t.starts_with("fragment ") || t.starts_with("fragment\t")
799 });
800 stages_from_flags(has_vertex, has_fragment)
801}
802
803pub(crate) fn detect_wgsl_stages(source: &str) -> Vec<&'static str> {
807 stages_from_flags(source.contains("@vertex"), source.contains("@fragment"))
808}
809
810fn stages_from_flags(has_vertex: bool, has_fragment: bool) -> Vec<&'static str> {
811 let mut stages = Vec::new();
812 if has_vertex {
813 stages.push("vertex");
814 }
815 if has_fragment {
816 stages.push("fragment");
817 }
818 if stages.is_empty() {
819 stages.push("vertex");
820 }
821 stages
822}
823
824fn read_source_file(path_str: &str) -> std::io::Result<String> {
825 std::fs::read_to_string(path_str)
826 .map_err(|e| std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e)))
827}
828
829const TEXT_LABEL_MAX_BYTES: usize = 64 * 1024;
833
834fn read_text_content(path_str: &str) -> std::io::Result<String> {
841 let metadata = std::fs::metadata(path_str).map_err(|e| {
842 std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e))
843 })?;
844 if metadata.len() as usize > TEXT_LABEL_MAX_BYTES {
845 return Err(std::io::Error::new(
846 std::io::ErrorKind::InvalidInput,
847 format!(
848 "'{}' is {} bytes; TextLabel content is capped at {} bytes: \
849 trim the file or add it as a `File` asset via inline JSON instead",
850 path_str,
851 metadata.len(),
852 TEXT_LABEL_MAX_BYTES
853 ),
854 ));
855 }
856 let mut content = std::fs::read_to_string(path_str).map_err(|e| {
857 std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e))
858 })?;
859 if content.ends_with('\n') {
860 content.pop();
861 if content.ends_with('\r') {
862 content.pop();
863 }
864 }
865 Ok(content)
866}
867
868fn text_label_entry(name: &str, path_str: &str) -> std::io::Result<serde_json::Value> {
869 validated_entry(
870 name,
871 "TextLabel",
872 serde_json::json!({
873 "content": read_text_content(path_str)?,
874 "centered": true,
875 }),
876 )
877}
878
879fn md_has_frontmatter(path_str: &str) -> std::io::Result<bool> {
883 use std::io::BufRead;
884 let file = std::fs::File::open(path_str).map_err(|e| {
885 std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e))
886 })?;
887 let mut first = String::new();
888 std::io::BufReader::new(file).read_line(&mut first)?;
889 Ok(first.trim_start_matches('\u{feff}').trim_end() == "---")
890}
891
892fn entry_from_json_file(
893 path: &std::path::Path,
894 stem_name: &str,
895) -> std::io::Result<serde_json::Value> {
896 let content = std::fs::read_to_string(path).map_err(|e| {
897 std::io::Error::new(
898 e.kind(),
899 format!("could not read '{}': {}", path.display(), e),
900 )
901 })?;
902 let json: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
903 std::io::Error::new(
904 std::io::ErrorKind::InvalidData,
905 format!("could not parse '{}': {}", path.display(), e),
906 )
907 })?;
908
909 let asset_type = json.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
910 std::io::Error::new(
911 std::io::ErrorKind::InvalidData,
912 format!("'{}' has no `type` field", path.display()),
913 )
914 })?;
915
916 if asset_type.to_lowercase().replace('_', "") == "buildconfig" {
917 return Err(std::io::Error::new(
918 std::io::ErrorKind::InvalidInput,
919 format!(
920 "BuildConfig cannot be added via `concinnity add`; edit {} directly",
921 WORLD_JSONL
922 ),
923 ));
924 }
925
926 let args = json
927 .get("args")
928 .cloned()
929 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
930
931 let name = json
932 .get("name")
933 .and_then(|v| v.as_str())
934 .unwrap_or(stem_name)
935 .to_string();
936
937 validated_entry(&name, asset_type, args)
938}
939
940fn entry_from_inline_json(raw: &str) -> std::io::Result<serde_json::Value> {
941 let json: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
942 std::io::Error::new(
943 std::io::ErrorKind::InvalidData,
944 format!("could not parse inline JSON: {}", e),
945 )
946 })?;
947
948 if !json.is_object() {
949 return Err(std::io::Error::new(
950 std::io::ErrorKind::InvalidData,
951 "inline JSON must be an object (e.g. '{\"type\": \"Window\"}')",
952 ));
953 }
954
955 let asset_type = json.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
956 std::io::Error::new(
957 std::io::ErrorKind::InvalidData,
958 "inline JSON must contain a `type` field",
959 )
960 })?;
961
962 if asset_type.to_lowercase().replace('_', "") == "buildconfig" {
963 return Err(std::io::Error::new(
964 std::io::ErrorKind::InvalidInput,
965 format!(
966 "BuildConfig cannot be added via `concinnity add`; edit {} directly",
967 WORLD_JSONL
968 ),
969 ));
970 }
971
972 let name = json
973 .get("name")
974 .and_then(|v| v.as_str())
975 .map(str::to_string)
976 .unwrap_or_else(|| asset_type.to_lowercase());
977
978 let args = json
979 .get("args")
980 .cloned()
981 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
982
983 let req = AssetRequest {
984 asset_type: asset_type.to_string(),
985 args: Some(args.clone()),
986 };
987 create_asset_def(&req, crate::cook_platform())
988 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
989 let resolved_args = normalized_args_value(asset_type, &args);
990
991 Ok(serde_json::json!({
992 "name": name,
993 "type": asset_type,
994 "args": resolved_args,
995 }))
996}
997
998pub(crate) fn resolve_add_target(target: &str) -> std::io::Result<Vec<serde_json::Value>> {
1002 if is_path_like(target) {
1003 return entry_from_path(target);
1004 }
1005
1006 if RegisteredType::parse(target).is_some() {
1007 return entry_from_type_name(target).map(|e| vec![e]);
1008 }
1009
1010 let as_path = std::path::Path::new(target);
1011 if as_path.exists() && as_path.is_file() {
1012 let name = as_path
1013 .file_name()
1014 .and_then(|s| s.to_str())
1015 .map(|s| s.replace('.', "_"))
1016 .unwrap_or_else(|| target.to_string());
1017 return entry_from_json_file(as_path, &name).map(|e| vec![e]);
1018 }
1019
1020 if target.trim_start().starts_with('{') {
1021 return entry_from_inline_json(target).map(|e| vec![e]);
1022 }
1023
1024 Err(std::io::Error::new(
1025 std::io::ErrorKind::InvalidInput,
1026 format!(
1027 "could not resolve '{}' as any of:\n \
1028 - a file path (no such file found)\n \
1029 - a known asset type (use `concinnity list` to see available types)\n \
1030 - an inline JSON object (must start with '{{' and contain a `type` field)",
1031 target
1032 ),
1033 ))
1034}
1035
1036fn entry_from_type_name(type_str: &str) -> std::io::Result<serde_json::Value> {
1037 if type_str.to_lowercase().replace('_', "") == "buildconfig" {
1038 return Err(std::io::Error::new(
1039 std::io::ErrorKind::InvalidInput,
1040 format!(
1041 "BuildConfig cannot be added via `concinnity add`; edit {} directly",
1042 WORLD_JSONL
1043 ),
1044 ));
1045 }
1046
1047 let req = AssetRequest {
1048 asset_type: type_str.to_string(),
1049 args: None,
1050 };
1051 create_asset_def(&req, crate::cook_platform())
1052 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
1053 let args = normalized_args_value(type_str, &serde_json::Value::Object(Default::default()));
1054
1055 let name = type_str.to_lowercase();
1056
1057 Ok(serde_json::json!({
1058 "name": name,
1059 "type": type_str,
1060 "args": args,
1061 }))
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066 use super::*;
1067
1068 #[test]
1071 fn metal_both_stages() {
1072 let src = "vertex VertexOut vert_main() {}\nfragment float4 frag_main() {}";
1073 assert_eq!(detect_metal_stages(src), vec!["vertex", "fragment"]);
1074 }
1075
1076 #[test]
1077 fn metal_vertex_only() {
1078 let src = "vertex VertexOut vert_main() {}";
1079 assert_eq!(detect_metal_stages(src), vec!["vertex"]);
1080 }
1081
1082 #[test]
1083 fn metal_fragment_only() {
1084 let src = "fragment float4 frag_main() {}";
1085 assert_eq!(detect_metal_stages(src), vec!["fragment"]);
1086 }
1087
1088 #[test]
1089 fn metal_no_qualifiers_defaults_to_vertex() {
1090 let src = "// helper only\nfloat4 helper() { return float4(1.0); }";
1091 assert_eq!(detect_metal_stages(src), vec!["vertex"]);
1092 }
1093
1094 #[test]
1095 fn metal_tab_separated_qualifier() {
1096 let src = "vertex\tVertexOut vert_main() {}";
1097 assert_eq!(detect_metal_stages(src), vec!["vertex"]);
1098 }
1099
1100 #[test]
1101 fn metal_indented_qualifier_still_detected() {
1102 let src = " vertex VertexOut vert_main() {}\n fragment float4 frag_main() {}";
1104 assert_eq!(detect_metal_stages(src), vec!["vertex", "fragment"]);
1105 }
1106
1107 #[test]
1110 fn wgsl_both_stages() {
1111 let src =
1112 "@vertex\nfn vs() -> VertexOutput {}\n@fragment\nfn fs() -> @location(0) vec4<f32> {}";
1113 assert_eq!(detect_wgsl_stages(src), vec!["vertex", "fragment"]);
1114 }
1115
1116 #[test]
1117 fn wgsl_vertex_only() {
1118 let src = "@vertex fn vs() {}";
1119 assert_eq!(detect_wgsl_stages(src), vec!["vertex"]);
1120 }
1121
1122 #[test]
1123 fn wgsl_no_attributes_defaults_to_vertex() {
1124 let src = "fn helper() -> f32 { return 1.0; }";
1125 assert_eq!(detect_wgsl_stages(src), vec!["vertex"]);
1126 }
1127
1128 #[test]
1129 fn a_two_stage_source_becomes_one_shader_entry() {
1130 let entries =
1131 shader_entries_from_stages("s.metal", "s", "metal", &["vertex", "fragment"]).unwrap();
1132 assert_eq!(entries.len(), 1);
1133 assert_eq!(entries[0]["name"], "s_shader");
1134 assert_eq!(entries[0]["type"], "Shader");
1135 assert_eq!(entries[0]["args"]["vertex"]["source"], "s.metal");
1136 assert_eq!(entries[0]["args"]["fragment"]["source"], "s.metal");
1137 }
1138
1139 #[test]
1140 fn a_single_stage_source_is_rejected_with_guidance() {
1141 let err = shader_entries_from_stages("s.metal", "s", "metal", &["vertex"]).unwrap_err();
1142 assert!(err.to_string().contains("both a vertex and a fragment"));
1143 }
1144
1145 #[test]
1146 fn glsl_pair_swaps_the_stage_marker_and_shares_a_stem() {
1147 let dir = tempfile::tempdir().unwrap();
1148 let vert = dir.path().join("foo_vert.glsl");
1149 let frag = dir.path().join("foo_frag.glsl");
1150 std::fs::write(&vert, "").unwrap();
1151 std::fs::write(&frag, "").unwrap();
1152
1153 let from_vert = glsl_pair(&vert, vert.to_str().unwrap(), "foo_vert").unwrap();
1155 let from_frag = glsl_pair(&frag, frag.to_str().unwrap(), "foo_frag").unwrap();
1156 assert_eq!(from_vert.0, vert.to_str().unwrap());
1157 assert_eq!(from_vert.1, frag.to_str().unwrap());
1158 assert_eq!(from_vert.0, from_frag.0);
1159 assert_eq!(from_vert.1, from_frag.1);
1160 assert_eq!(from_vert.2, "foo");
1161 assert_eq!(from_frag.2, "foo");
1162
1163 std::fs::remove_file(&frag).unwrap();
1165 assert!(glsl_pair(&vert, vert.to_str().unwrap(), "foo_vert").is_err());
1166 }
1167
1168 #[test]
1171 fn font_name_uses_stem_only() {
1172 let path = std::path::Path::new("fonts/JetBrainsMono-Regular.ttf");
1173 let stem = path
1174 .file_stem()
1175 .and_then(|s| s.to_str())
1176 .map(|s| s.replace('.', "_"))
1177 .unwrap_or_default();
1178 assert_eq!(stem, "JetBrainsMono-Regular");
1180 assert!(!stem.contains("ttf"));
1181 }
1182
1183 #[test]
1186 fn unknown_extension_errors() {
1187 let result = entry_from_path("assets/shader.xyz");
1189 assert!(result.is_err());
1190 let msg = result.unwrap_err().to_string();
1191 assert!(msg.contains(".xyz"));
1192 }
1193
1194 #[test]
1197 fn stages_from_flags_neither() {
1198 assert_eq!(stages_from_flags(false, false), vec!["vertex"]);
1199 }
1200
1201 #[test]
1202 fn stages_from_flags_both() {
1203 assert_eq!(stages_from_flags(true, true), vec!["vertex", "fragment"]);
1204 }
1205
1206 #[test]
1207 fn stages_from_flags_fragment_only() {
1208 assert_eq!(stages_from_flags(false, true), vec!["fragment"]);
1209 }
1210
1211 #[test]
1214 fn target_bootstrap_kind_glb_is_scene() {
1215 assert!(matches!(
1216 target_bootstrap_kind("models/scene.glb"),
1217 BootstrapKind::Scene
1218 ));
1219 assert!(matches!(
1220 target_bootstrap_kind("scene.GLB"),
1221 BootstrapKind::Scene
1222 ));
1223 }
1224
1225 #[test]
1226 fn target_bootstrap_kind_text_is_text() {
1227 assert!(matches!(
1228 target_bootstrap_kind("notes.txt"),
1229 BootstrapKind::Text
1230 ));
1231 assert!(matches!(
1232 target_bootstrap_kind("README.MD"),
1233 BootstrapKind::Text
1234 ));
1235 }
1236
1237 #[test]
1238 fn target_bootstrap_kind_other_is_none() {
1239 assert!(matches!(
1240 target_bootstrap_kind("Logger"),
1241 BootstrapKind::None
1242 ));
1243 assert!(matches!(
1244 target_bootstrap_kind("shader.vert"),
1245 BootstrapKind::None
1246 ));
1247 assert!(matches!(
1248 target_bootstrap_kind("font.ttf"),
1249 BootstrapKind::None
1250 ));
1251 }
1252
1253 #[test]
1256 fn renderer_trigger_matches_graphics_config() {
1257 let jsonl = r#"{"name":"gc","type":"GraphicsConfig","args":{}}"#;
1258 assert!(jsonl_has_renderer_trigger(jsonl));
1259 }
1260
1261 #[test]
1262 fn renderer_trigger_matches_text_label() {
1263 let jsonl = r#"{"name":"lbl","type":"TextLabel","args":{}}"#;
1264 assert!(jsonl_has_renderer_trigger(jsonl));
1265 }
1266
1267 #[test]
1270 fn renderer_trigger_matches_prop() {
1271 let jsonl = r#"{"name":"crate","type":"Prop","args":{}}"#;
1272 assert!(jsonl_has_renderer_trigger(jsonl));
1273 }
1274
1275 #[test]
1278 fn renderer_trigger_ignores_window() {
1279 let jsonl = r#"{"name":"win","type":"Window","args":{}}"#;
1280 assert!(!jsonl_has_renderer_trigger(jsonl));
1281 }
1282
1283 #[test]
1284 fn renderer_trigger_absent_for_render_data_only_world() {
1285 let jsonl = concat!(
1288 r#"{"name":"tex","type":"Texture","args":{}}"#,
1289 "\n",
1290 r#"{"name":"mat","type":"Material","args":{}}"#,
1291 "\n",
1292 r#"{"name":"mesh","type":"Mesh","args":{}}"#,
1293 "\n",
1294 r#"{"name":"model","type":"Model","args":{}}"#,
1295 "\n",
1296 r#"{"name":"cam","type":"Camera3D","args":{}}"#,
1297 "\n",
1298 );
1299 assert!(!jsonl_has_renderer_trigger(jsonl));
1300 }
1301
1302 #[test]
1303 fn renderer_trigger_skips_malformed_lines() {
1304 let jsonl = concat!(
1305 "garbage line\n",
1306 r#"{"name":"tex","type":"Texture","args":{}}"#,
1307 "\n",
1308 );
1309 assert!(!jsonl_has_renderer_trigger(jsonl));
1310 }
1311
1312 #[test]
1313 fn renderer_trigger_handles_empty_jsonl() {
1314 assert!(!jsonl_has_renderer_trigger(""));
1315 }
1316
1317 #[test]
1320 fn scaffold_to_inject_writes_nothing_without_a_template() {
1321 let dir = concinnity_testing::TempTree::new();
1324 let world = dir.join("world.jsonl");
1325 std::fs::write(
1326 &world,
1327 concat!(
1328 r#"{"name":"tex","type":"Texture","args":{}}"#,
1329 "\n",
1330 r#"{"name":"cam","type":"Camera3D","args":{}}"#,
1331 "\n",
1332 ),
1333 )
1334 .unwrap();
1335
1336 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", None).unwrap();
1337 assert!(scaffold.is_empty());
1338 }
1339
1340 #[test]
1341 fn scaffold_to_inject_returns_empty_when_world_has_graphics_config() {
1342 let dir = concinnity_testing::TempTree::new();
1343 let world = dir.join("world.jsonl");
1344 std::fs::write(
1345 &world,
1346 r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
1347 )
1348 .unwrap();
1349
1350 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", None).unwrap();
1351 assert!(scaffold.is_empty());
1352 }
1353
1354 #[test]
1355 fn scaffold_to_inject_allows_missing_world_with_glb() {
1356 let dir = concinnity_testing::TempTree::new();
1360 let world = dir.join("world.jsonl");
1361
1362 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", None).unwrap();
1363 assert!(scaffold.is_empty());
1364 }
1365
1366 #[test]
1367 fn scaffold_to_inject_errors_for_missing_world_with_non_scene_target() {
1368 let dir = concinnity_testing::TempTree::new();
1369 let world = dir.join("world.jsonl");
1370
1371 let err = scaffold_to_inject(world.to_str().unwrap(), "Logger", None)
1372 .expect_err("missing world + non-scene target must error");
1373 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1374 }
1375
1376 #[test]
1377 fn scaffold_to_inject_returns_empty_for_non_scene_target_into_existing_world() {
1378 let dir = concinnity_testing::TempTree::new();
1379 let world = dir.join("world.jsonl");
1380 std::fs::write(&world, "").unwrap();
1381
1382 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "Logger", None).unwrap();
1385 assert!(scaffold.is_empty());
1386 }
1387
1388 #[test]
1391 fn scaffold_to_inject_uses_named_template() {
1392 let dir = concinnity_testing::TempTree::new();
1393 let world = dir.join("world.jsonl");
1394
1395 let name = concinnity_cook::authoring::template::TEMPLATES[0].name;
1396 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", Some(name))
1397 .expect("a named template should apply for a renderer-less glb add");
1398 let expected = concinnity_cook::authoring::template::by_name(name)
1399 .unwrap()
1400 .assets()
1401 .len();
1402 assert_eq!(scaffold.len(), expected, "expected the template's entries");
1403 assert!(!scaffold.is_empty());
1404 }
1405
1406 #[test]
1412 fn every_template_entry_validates_as_a_real_asset() {
1413 let _guard = crate::test_support::lock();
1414 for t in concinnity_cook::authoring::template::TEMPLATES {
1415 let entries = crate::authoring::template_spec::world_template_entries(t);
1416 assert!(!entries.is_empty(), "template '{}' is empty", t.name);
1417 for entry in entries {
1418 let ty = entry["type"].as_str().expect("entry has a type");
1419 let name = entry["name"].as_str().expect("entry has a name");
1420 let args = entry
1421 .get("args")
1422 .cloned()
1423 .unwrap_or_else(|| serde_json::json!({}));
1424 concinnity_cook::validate_asset(ty, name, &args, crate::cook_platform())
1425 .unwrap_or_else(|e| {
1426 panic!(
1427 "template '{}' entry '{name}' ({ty}) failed to validate: {e}",
1428 t.name
1429 )
1430 });
1431 }
1432 }
1433 }
1434
1435 #[test]
1436 fn scaffold_to_inject_errors_on_unknown_template() {
1437 let dir = concinnity_testing::TempTree::new();
1438 let world = dir.join("world.jsonl");
1439
1440 let err = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", Some("nope"))
1441 .expect_err("unknown template name must surface as InvalidInput");
1442 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1443 assert!(
1444 err.to_string().contains("nope"),
1445 "error should name the bad template: {err}"
1446 );
1447 }
1448
1449 #[test]
1450 fn scaffold_to_inject_rejects_unknown_template_even_when_no_scaffold_would_fire() {
1451 let dir = concinnity_testing::TempTree::new();
1455 let world = dir.join("world.jsonl");
1456 std::fs::write(
1458 &world,
1459 r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
1460 )
1461 .unwrap();
1462
1463 let err = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", Some("nope"))
1464 .expect_err("unknown template should fail fast regardless of scaffold path");
1465 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1466 }
1467
1468 #[test]
1471 fn text_file_becomes_text_label_with_content() {
1472 let dir = concinnity_testing::TempTree::new();
1473 let path = dir.join("greeting.txt");
1474 std::fs::write(&path, "Hello, world!").unwrap();
1475
1476 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1477 assert_eq!(entries.len(), 1);
1478 let entry = &entries[0];
1479 assert_eq!(entry["type"], "TextLabel");
1480 assert_eq!(entry["name"], "greeting");
1481 assert_eq!(entry["args"]["content"], "Hello, world!");
1482 assert_eq!(entry["args"]["centered"], serde_json::json!(true));
1484 }
1485
1486 #[test]
1487 fn text_file_strips_single_trailing_newline() {
1488 let dir = concinnity_testing::TempTree::new();
1489 let lf = dir.join("lf.txt");
1490 std::fs::write(&lf, "line one\nline two\n").unwrap();
1491 let crlf = dir.join("crlf.md");
1492 std::fs::write(&crlf, "line one\r\nline two\r\n").unwrap();
1493
1494 let lf_entry = &entry_from_path(lf.to_str().unwrap()).unwrap()[0];
1495 assert_eq!(lf_entry["args"]["content"], "line one\nline two");
1496 let crlf_entry = &entry_from_path(crlf.to_str().unwrap()).unwrap()[0];
1497 assert_eq!(crlf_entry["args"]["content"], "line one\r\nline two");
1498 }
1499
1500 #[test]
1501 fn audio_file_becomes_audio_clip() {
1502 let dir = concinnity_testing::TempTree::new();
1503 let path = dir.join("door_creak.wav");
1504 std::fs::write(&path, b"not-really-audio").unwrap();
1505
1506 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1507 assert_eq!(entries.len(), 1);
1508 let entry = &entries[0];
1509 assert_eq!(entry["type"], "AudioClip");
1510 assert_eq!(entry["name"], "door_creak");
1511 assert_eq!(entry["args"]["source"], path.to_str().unwrap());
1512 }
1513
1514 #[test]
1515 fn hdr_file_becomes_an_environment_map() {
1516 let dir = concinnity_testing::TempTree::new();
1517 let path = dir.join("san_giuseppe_4k.hdr");
1518 std::fs::write(&path, b"not-really-radiance").unwrap();
1519
1520 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1521 assert_eq!(entries.len(), 1);
1522 let entry = &entries[0];
1523 assert_eq!(entry["type"], "EnvironmentMap");
1524 assert_eq!(entry["name"], "san_giuseppe_4k");
1525 assert_eq!(entry["args"]["source"], path.to_str().unwrap());
1526 assert_eq!(entry["args"]["generator"], "");
1529 assert_eq!(entry["args"]["prefilter_face_size"], serde_json::json!(512));
1531 }
1532
1533 #[test]
1535 fn an_hdr_entry_validates_against_the_environment_map_schema() {
1536 let _guard = crate::test_support::lock();
1537 let dir = concinnity_testing::TempTree::new();
1538 let path = dir.join("studio.hdr");
1539 std::fs::write(&path, b"radiance").unwrap();
1540
1541 let entry = entry_from_path(path.to_str().unwrap()).unwrap().remove(0);
1542 concinnity_cook::validate_asset(
1543 "EnvironmentMap",
1544 "studio",
1545 &entry["args"],
1546 crate::cook_platform(),
1547 )
1548 .expect("a `.hdr` add must produce a cookable EnvironmentMap");
1549 }
1550
1551 #[test]
1557 fn try_retarget_environment_map_repoints_the_existing_map() {
1558 let mut assets = vec![serde_json::json!({
1559 "name": "env_sky",
1560 "type": "EnvironmentMap",
1561 "args": {
1562 "source": "assets/hdri/old.hdr",
1563 "generator": "",
1564 "prefilter_face_size": 1024,
1565 "prefilter_clamp": 4.0,
1566 }
1567 })];
1568 let entries = vec![serde_json::json!({
1569 "name": "studio",
1570 "type": "EnvironmentMap",
1571 "args": {"source": "assets/hdri/studio.hdr", "generator": ""}
1572 })];
1573
1574 let out = try_retarget_environment_map(&mut assets, &entries);
1575 assert_eq!(
1576 out,
1577 Some(("env_sky".to_string(), "assets/hdri/studio.hdr".to_string()))
1578 );
1579 assert_eq!(assets.len(), 1, "no second map appended");
1580 let args = assets[0]["args"].as_object().unwrap();
1581 assert_eq!(args["source"], "assets/hdri/studio.hdr");
1582 assert_eq!(args["prefilter_face_size"], 1024);
1584 assert_eq!(args["prefilter_clamp"], 4.0);
1585 assert_eq!(assets[0]["name"], "env_sky");
1587 }
1588
1589 #[test]
1592 fn try_retarget_environment_map_clears_a_generator() {
1593 let mut assets = vec![serde_json::json!({
1594 "name": "env",
1595 "type": "EnvironmentMap",
1596 "args": {"source": "", "generator": "sky"}
1597 })];
1598 let entries = vec![serde_json::json!({
1599 "name": "dusk",
1600 "type": "EnvironmentMap",
1601 "args": {"source": "dusk.hdr", "generator": ""}
1602 })];
1603
1604 assert!(try_retarget_environment_map(&mut assets, &entries).is_some());
1605 let args = assets[0]["args"].as_object().unwrap();
1606 assert_eq!(args["source"], "dusk.hdr");
1607 assert_eq!(args["generator"], "");
1608 }
1609
1610 #[test]
1612 fn try_retarget_environment_map_takes_the_first_of_several() {
1613 let mut assets = vec![
1614 serde_json::json!({"name": "a", "type": "EnvironmentMap", "args": {"source": "a.hdr"}}),
1615 serde_json::json!({"name": "b", "type": "EnvironmentMap", "args": {"source": "b.hdr"}}),
1616 ];
1617 let entries = vec![
1618 serde_json::json!({"name": "c", "type": "EnvironmentMap", "args": {"source": "c.hdr"}}),
1619 ];
1620
1621 let (name, _) = try_retarget_environment_map(&mut assets, &entries).unwrap();
1622 assert_eq!(name, "a");
1623 assert_eq!(assets[0]["args"]["source"], "c.hdr");
1624 assert_eq!(
1625 assets[1]["args"]["source"], "b.hdr",
1626 "the rest are untouched"
1627 );
1628 }
1629
1630 #[test]
1632 fn try_retarget_environment_map_skips_a_world_without_one() {
1633 let mut assets = vec![serde_json::json!({
1634 "name": "lamp", "type": "PointLight", "args": {}
1635 })];
1636 let entries = vec![
1637 serde_json::json!({"name": "env", "type": "EnvironmentMap", "args": {"source": "e.hdr"}}),
1638 ];
1639 assert!(try_retarget_environment_map(&mut assets, &entries).is_none());
1640 }
1641
1642 #[test]
1643 fn try_retarget_environment_map_skips_other_types() {
1644 let mut assets = vec![serde_json::json!({
1645 "name": "env", "type": "EnvironmentMap", "args": {"source": "e.hdr"}
1646 })];
1647 let entries =
1648 vec![serde_json::json!({"name": "face", "type": "Font", "args": {"path": "face.ttf"}})];
1649 assert!(try_retarget_environment_map(&mut assets, &entries).is_none());
1650 assert_eq!(assets[0]["args"]["source"], "e.hdr");
1651 }
1652
1653 #[test]
1655 fn try_retarget_environment_map_skips_multi_entry() {
1656 let mut assets = vec![serde_json::json!({
1657 "name": "env", "type": "EnvironmentMap", "args": {"source": "e.hdr"}
1658 })];
1659 let entries = vec![
1660 serde_json::json!({"name": "a", "type": "EnvironmentMap", "args": {"source": "a.hdr"}}),
1661 serde_json::json!({"name": "b", "type": "EnvironmentMap", "args": {"source": "b.hdr"}}),
1662 ];
1663 assert!(try_retarget_environment_map(&mut assets, &entries).is_none());
1664 }
1665
1666 #[test]
1667 fn markdown_with_frontmatter_becomes_story_import() {
1668 let dir = concinnity_testing::TempTree::new();
1669 let path = dir.join("crossroads.md");
1670 std::fs::write(&path, "---\ntitle: T\n---\n\n# a\n\nhi\n").unwrap();
1671
1672 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1673 assert_eq!(entries.len(), 1);
1674 let entry = &entries[0];
1675 assert_eq!(entry["type"], "StoryImport");
1676 assert_eq!(entry["name"], "crossroads");
1677 assert_eq!(entry["args"]["source"], path.to_str().unwrap());
1678 assert_eq!(entry["args"]["title_screen"], serde_json::json!(true));
1680 }
1681
1682 #[test]
1683 fn markdown_without_frontmatter_stays_a_text_label() {
1684 let dir = concinnity_testing::TempTree::new();
1685 let path = dir.join("notes.md");
1686 std::fs::write(&path, "# Notes\n\nplain markdown\n").unwrap();
1687
1688 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1689 assert_eq!(entries.len(), 1);
1690 assert_eq!(entries[0]["type"], "TextLabel");
1691 }
1692
1693 #[test]
1694 fn text_file_over_size_cap_errors() {
1695 let dir = concinnity_testing::TempTree::new();
1696 let path = dir.join("huge.txt");
1697 let blob = "a".repeat(TEXT_LABEL_MAX_BYTES + 1);
1698 std::fs::write(&path, blob).unwrap();
1699
1700 let err = entry_from_path(path.to_str().unwrap())
1701 .expect_err("oversized text file must fail rather than silently truncate");
1702 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1703 assert!(err.to_string().contains("capped"));
1704 }
1705
1706 #[test]
1707 fn scaffold_to_inject_empty_for_missing_world_with_text_target() {
1708 let dir = concinnity_testing::TempTree::new();
1711 let world = dir.join("world.jsonl");
1712
1713 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "notes.txt", None).unwrap();
1714 assert!(scaffold.is_empty(), "text target should emit no scaffold");
1715 }
1716
1717 #[test]
1718 fn scaffold_to_inject_empty_for_renderer_less_world_with_text_target() {
1719 let dir = concinnity_testing::TempTree::new();
1720 let world = dir.join("world.jsonl");
1721 std::fs::write(&world, r#"{"name":"tex","type":"Texture","args":{}}"#).unwrap();
1722
1723 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "notes.md", None).unwrap();
1724 assert!(
1725 scaffold.is_empty(),
1726 "text target never injects GLB scaffold"
1727 );
1728 }
1729
1730 #[test]
1733 fn try_refresh_text_label_updates_content_in_place() {
1734 let mut assets = vec![serde_json::json!({
1737 "name": "greeting",
1738 "type": "TextLabel",
1739 "args": {
1740 "content": "Old text",
1741 "font": "Questrial-Regular",
1742 "x": 10.0,
1743 "y": 200.0,
1744 "color": [0.2, 0.8, 0.4],
1745 "scale": 1.5,
1746 "centered": false,
1747 "background": [0.0, 0.0, 0.0, 0.0],
1748 "padding": 0.0,
1749 "visible": true,
1750 "screen": null,
1751 }
1752 })];
1753 let entries = vec![serde_json::json!({
1754 "name": "greeting",
1755 "type": "TextLabel",
1756 "args": {
1757 "content": "New text",
1758 "centered": true,
1759 }
1760 })];
1761
1762 let refreshed = try_refresh_text_label(&mut assets, &entries);
1763 assert_eq!(refreshed.as_deref(), Some("greeting"));
1764 let args = assets[0]["args"].as_object().unwrap();
1765 assert_eq!(args["content"], "New text");
1766 assert_eq!(args["y"], 200.0);
1768 assert_eq!(args["color"], serde_json::json!([0.2, 0.8, 0.4]));
1769 assert_eq!(args["scale"], 1.5);
1770 assert_eq!(args["centered"], false);
1771 }
1772
1773 #[test]
1774 fn try_refresh_text_label_skips_non_textlabel_new_entry() {
1775 let mut assets = vec![serde_json::json!({
1776 "name": "thing",
1777 "type": "TextLabel",
1778 "args": {"content": "old"}
1779 })];
1780 let entries = vec![serde_json::json!({
1781 "name": "thing",
1782 "type": "Font",
1783 "args": {"path": "x.ttf"}
1784 })];
1785 assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1786 }
1787
1788 #[test]
1789 fn try_refresh_text_label_skips_when_existing_is_different_type() {
1790 let mut assets = vec![serde_json::json!({
1791 "name": "thing",
1792 "type": "Font",
1793 "args": {"path": "x.ttf"}
1794 })];
1795 let entries = vec![serde_json::json!({
1796 "name": "thing",
1797 "type": "TextLabel",
1798 "args": {"content": "hi"}
1799 })];
1800 assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1803 }
1804
1805 #[test]
1806 fn try_refresh_text_label_skips_when_name_misses() {
1807 let mut assets = vec![serde_json::json!({
1808 "name": "greeting",
1809 "type": "TextLabel",
1810 "args": {"content": "old"}
1811 })];
1812 let entries = vec![serde_json::json!({
1813 "name": "caption",
1814 "type": "TextLabel",
1815 "args": {"content": "new"}
1816 })];
1817 assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1818 }
1819
1820 #[test]
1821 fn try_refresh_text_label_skips_multi_entry() {
1822 let mut assets = vec![serde_json::json!({
1825 "name": "label",
1826 "type": "TextLabel",
1827 "args": {"content": "old"}
1828 })];
1829 let entries = vec![
1830 serde_json::json!({"name": "label", "type": "TextLabel", "args": {"content": "new"}}),
1831 serde_json::json!({"name": "other", "type": "TextLabel", "args": {"content": "other"}}),
1832 ];
1833 assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1834 }
1835
1836 #[test]
1837 fn scaffold_to_inject_rejects_template_with_text_target() {
1838 let dir = concinnity_testing::TempTree::new();
1839 let world = dir.join("world.jsonl");
1840
1841 let name = concinnity_cook::authoring::template::TEMPLATES[0].name;
1842 let err = scaffold_to_inject(world.to_str().unwrap(), "notes.txt", Some(name))
1843 .expect_err("--template should only apply to scene targets");
1844 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1845 }
1846
1847 #[test]
1850 fn inline_json_must_be_an_object() {
1851 let err = entry_from_inline_json("[1, 2]").unwrap_err();
1852 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1853 assert!(err.to_string().contains("must be an object"), "got: {err}");
1854 }
1855
1856 #[test]
1857 fn inline_json_requires_a_type_field() {
1858 let err = entry_from_inline_json(r#"{"name":"thing"}"#).unwrap_err();
1859 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1860 assert!(err.to_string().contains("`type` field"), "got: {err}");
1861 }
1862
1863 #[test]
1864 fn inline_json_rejects_malformed_text() {
1865 let err = entry_from_inline_json("{ nope").unwrap_err();
1866 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1867 }
1868
1869 #[test]
1870 fn inline_json_rejects_build_config() {
1871 let err = entry_from_inline_json(r#"{"type":"BuildConfig"}"#).unwrap_err();
1872 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1873 let err = entry_from_inline_json(r#"{"type":"build_config"}"#).unwrap_err();
1875 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1876 }
1877
1878 #[test]
1879 fn inline_json_defaults_the_name_to_the_lowercased_type() {
1880 let entry = entry_from_inline_json(r#"{"type":"Window"}"#).unwrap();
1881 assert_eq!(entry["name"], "window");
1882 assert_eq!(entry["type"], "Window");
1883 assert!(entry["args"].is_object());
1885 }
1886
1887 #[test]
1888 fn inline_json_keeps_an_explicit_name() {
1889 let entry = entry_from_inline_json(r#"{"type":"Window","name":"main"}"#).unwrap();
1890 assert_eq!(entry["name"], "main");
1891 }
1892
1893 #[test]
1896 fn type_name_builds_a_default_entry() {
1897 let entry = entry_from_type_name("Window").unwrap();
1898 assert_eq!(entry["name"], "window");
1899 assert_eq!(entry["type"], "Window");
1900 assert!(entry["args"].is_object());
1901 }
1902
1903 #[test]
1904 fn type_name_rejects_build_config() {
1905 let err = entry_from_type_name("BuildConfig").unwrap_err();
1906 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1907 }
1908
1909 #[test]
1910 fn type_name_rejects_an_unknown_type() {
1911 assert!(entry_from_type_name("NotARealAssetType").is_err());
1912 }
1913
1914 #[test]
1917 fn json_file_requires_a_type_field() {
1918 let dir = concinnity_testing::TempTree::new();
1919 let path = dir.join("thing.json");
1920 std::fs::write(&path, r#"{"name":"thing"}"#).unwrap();
1921
1922 let err = entry_from_json_file(&path, "thing").unwrap_err();
1923 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1924 assert!(err.to_string().contains("no `type` field"), "got: {err}");
1925 }
1926
1927 #[test]
1928 fn json_file_rejects_build_config() {
1929 let dir = concinnity_testing::TempTree::new();
1930 let path = dir.join("cfg.json");
1931 std::fs::write(&path, r#"{"type":"BuildConfig"}"#).unwrap();
1932
1933 let err = entry_from_json_file(&path, "cfg").unwrap_err();
1934 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1935 }
1936
1937 #[test]
1938 fn json_file_uses_the_stem_when_unnamed() {
1939 let dir = concinnity_testing::TempTree::new();
1940 let path = dir.join("main_window.json");
1941 std::fs::write(&path, r#"{"type":"Window","args":{}}"#).unwrap();
1942
1943 let entry = entry_from_json_file(&path, "main_window").unwrap();
1944 assert_eq!(entry["name"], "main_window");
1945 assert_eq!(entry["type"], "Window");
1946 }
1947
1948 #[test]
1949 fn json_file_read_and_parse_failures_are_reported() {
1950 let dir = concinnity_testing::TempTree::new();
1951 let missing = dir.join("missing.json");
1953 assert!(entry_from_json_file(&missing, "missing").is_err());
1954 let junk = dir.join("junk.json");
1956 std::fs::write(&junk, "{ nope").unwrap();
1957 let err = entry_from_json_file(&junk, "junk").unwrap_err();
1958 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1959 }
1960
1961 #[test]
1964 fn resolve_add_target_dispatches_type_names_and_inline_json() {
1965 let entries = resolve_add_target("Window").unwrap();
1966 assert_eq!(entries.len(), 1);
1967 assert_eq!(entries[0]["type"], "Window");
1968
1969 let entries = resolve_add_target(r#"{"type":"Window","name":"main"}"#).unwrap();
1970 assert_eq!(entries.len(), 1);
1971 assert_eq!(entries[0]["name"], "main");
1972 }
1973
1974 #[test]
1975 fn resolve_add_target_reports_an_unresolvable_target() {
1976 let err = resolve_add_target("DefinitelyNotAnAssetOrFile").unwrap_err();
1977 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1978 assert!(err.to_string().contains("could not resolve"), "got: {err}");
1979 }
1980
1981 #[test]
1985 fn import_extension_groups_track_the_dispatch() {
1986 let dir = tempfile::tempdir().unwrap();
1987 for (_, exts) in IMPORT_EXTENSION_GROUPS {
1988 for ext in *exts {
1989 let path = dir.path().join(format!("probe.{ext}"));
1990 std::fs::write(&path, b"{}").unwrap();
1992 let path_str = path.to_string_lossy();
1993 if let Err(e) = entry_from_path(&path_str) {
1994 assert!(
1995 !e.to_string().contains("unknown extension"),
1996 ".{ext} is advertised by the picker but not dispatched: {e}"
1997 );
1998 }
1999 }
2000 }
2001 }
2002
2003 #[test]
2005 fn import_extension_groups_are_non_empty_and_unique() {
2006 let mut seen = std::collections::HashSet::new();
2007 for (name, exts) in IMPORT_EXTENSION_GROUPS {
2008 assert!(!exts.is_empty(), "{name} has no extensions");
2009 for ext in *exts {
2010 assert!(seen.insert(*ext), "'{ext}' listed in two groups");
2011 assert!(
2012 !ext.starts_with('.'),
2013 "'{ext}' should be bare (rfd adds the dot)"
2014 );
2015 }
2016 }
2017 }
2018
2019 #[test]
2022 fn import_entry_sets_the_source_on_registration_defaults() {
2023 let entry = import_entry("SceneImport", "bistro", "scenes/bistro.fbx").unwrap();
2024 assert_eq!(entry["name"], "bistro");
2025 assert_eq!(entry["type"], "SceneImport");
2026 assert_eq!(entry["args"]["source"], "scenes/bistro.fbx");
2027 }
2028
2029 #[test]
2030 fn import_entry_rejects_an_unknown_type() {
2031 assert!(import_entry("NotARealAssetType", "x", "x.glb").is_err());
2032 }
2033
2034 #[test]
2041 fn a_glb_that_is_not_a_panorama_imports_as_geometry() {
2042 let dir = concinnity_testing::TempTree::new();
2043 let path = dir.join("scene.glb");
2044 std::fs::write(&path, b"not a panorama").unwrap();
2045
2046 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
2047 assert_eq!(entries.len(), 1);
2048 assert_eq!(entries[0]["type"], "SceneImport");
2049 assert_eq!(entries[0]["args"]["source"], path.to_str().unwrap());
2050 }
2051
2052 #[test]
2053 fn a_panorama_becomes_an_environment_map_naming_its_source() {
2054 let entry = environment_map_entry("galaxy", "assets/hdri/galaxy.glb").unwrap();
2055 assert_eq!(entry["name"], "galaxy");
2056 assert_eq!(entry["type"], "EnvironmentMap");
2057 assert_eq!(entry["args"]["source"], "assets/hdri/galaxy.glb");
2058 }
2059
2060 #[test]
2061 fn an_hdr_still_becomes_an_environment_map() {
2062 let dir = concinnity_testing::TempTree::new();
2063 let path = dir.join("studio.hdr");
2064 std::fs::write(&path, b"#?RADIANCE\n").unwrap();
2065
2066 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
2067 assert_eq!(entries[0]["type"], "EnvironmentMap");
2068 }
2069
2070 #[test]
2071 fn an_fbx_never_takes_the_panorama_branch() {
2072 let entries = entry_from_path("scenes/bistro.fbx").unwrap();
2073 assert_eq!(entries[0]["type"], "SceneImport");
2074 }
2075
2076 #[test]
2079 fn ensure_world_file_exists_creates_parents_and_is_idempotent() {
2080 let dir = concinnity_testing::TempTree::new();
2081 let world = dir.join("nested").join("deeper").join("world.jsonl");
2082
2083 ensure_world_file_exists(world.to_str().unwrap()).unwrap();
2084 assert!(world.exists());
2085 assert_eq!(std::fs::read_to_string(&world).unwrap(), "");
2086
2087 std::fs::write(&world, "content").unwrap();
2089 ensure_world_file_exists(world.to_str().unwrap()).unwrap();
2090 assert_eq!(std::fs::read_to_string(&world).unwrap(), "content");
2091 }
2092
2093 #[test]
2096 fn is_path_like_recognises_separators_prefixes_and_dotted_files() {
2097 assert!(is_path_like("models/scene.glb"));
2098 assert!(is_path_like("models\\scene.glb"));
2099 assert!(is_path_like("./scene.glb"));
2100 assert!(is_path_like("~/scene.glb"));
2101 assert!(is_path_like("scene.glb"));
2102 assert!(!is_path_like("Window"));
2104 }
2105}