1use crate::world::{WORLD_JSONL, patch_world_jsonl_to};
19use concinnity_cook::asset_api::{AssetRequest, create_asset_def};
20use concinnity_cook::build_from_path;
21use concinnity_world::registry::RegisteredType;
22
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) {
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_world::template::by_name(name) {
284 Some(t) => Ok(Some(
285 crate::authoring::template_spec::world_template_entries(t),
286 )),
287 None => Err(std::io::Error::new(
288 std::io::ErrorKind::InvalidInput,
289 format!(
290 "unknown template '{name}'; available: {}",
291 available_templates()
292 ),
293 )),
294 }
295}
296
297fn available_templates() -> String {
299 concinnity_world::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_world::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)
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).unwrap_or_else(|_| empty())
450}
451
452fn import_entry(asset_type: &str, name: &str, source: &str) -> std::io::Result<serde_json::Value> {
458 let reg = RegisteredType::parse(asset_type)
459 .ok_or_else(|| std::io::Error::other(format!("{} asset type is unavailable", asset_type)))?
460 .registration();
461 let mut args = reg
462 .default_args
463 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
464 if let serde_json::Value::Object(map) = &mut args {
465 map.insert(
466 "source".to_string(),
467 serde_json::Value::String(source.to_string()),
468 );
469 }
470 Ok(serde_json::json!({
471 "name": name,
472 "type": asset_type,
473 "args": args,
474 }))
475}
476
477pub(crate) const IMPORT_EXTENSION_GROUPS: &[(&str, &[&str])] = &[
483 ("Scenes", &["glb", "gltf", "fbx"]),
484 ("Stories & text", &["md", "txt"]),
485 ("Images", &["png", "jpg", "jpeg", "bmp", "tga", "gif"]),
486 ("Textures", &["ktx2"]),
487 ("Environment maps", &["hdr"]),
488 ("Audio", &["ogg", "wav", "mp3", "flac"]),
489 ("Fonts", &["ttf", "otf"]),
490 ("Shaders", &["vert", "frag", "glsl", "metal", "wgsl"]),
491 ("Models & data", &["obj", "mtl", "json", "gguf"]),
492];
493
494pub(crate) fn entry_from_path(path_str: &str) -> std::io::Result<Vec<serde_json::Value>> {
498 let path = std::path::Path::new(path_str);
499
500 let ext = path
501 .extension()
502 .and_then(|e| e.to_str())
503 .map(|e| e.to_lowercase())
504 .unwrap_or_default();
505
506 let stem = concinnity_cook::world::asset_name_from_path(path_str);
509
510 let base_name = if ext == "json" {
512 stem.clone()
513 } else {
514 path.file_name()
515 .and_then(|s| s.to_str())
516 .map(|s| s.replace('.', "_"))
517 .unwrap_or_else(|| path_str.to_string())
518 };
519
520 if ext == "json" {
521 return entry_from_json_file(path, &base_name).map(|e| vec![e]);
522 }
523
524 match ext.as_str() {
525 "vert" => {
528 let frag = sibling_path(path, "frag")?;
529 shader_pair_entry(&stem, path_str, &frag)
530 }
531 "frag" => {
532 let vert = sibling_path(path, "vert")?;
533 shader_pair_entry(&stem, &vert, path_str)
534 }
535
536 "glsl" => {
539 let (vert, frag, pair_stem) = glsl_pair(path, path_str, &stem)?;
540 shader_pair_entry(&pair_stem, &vert, &frag)
541 }
542
543 "metal" => {
545 let source = read_source_file(path_str)?;
546 shader_entries_from_stages(path_str, &stem, "metal", &detect_metal_stages(&source))
547 }
548
549 "wgsl" => {
551 let source = read_source_file(path_str)?;
552 shader_entries_from_stages(path_str, &stem, "wgsl", &detect_wgsl_stages(&source))
553 }
554
555 "ttf" | "otf" => Ok(vec![validated_entry(
557 &stem,
558 "Font",
559 serde_json::json!({ "path": path_str }),
560 )?]),
561
562 "gguf" => Ok(vec![validated_entry(
564 &base_name,
565 "LLM",
566 serde_json::json!({ "lib_path": "", "model_path": path_str }),
567 )?]),
568
569 "ogg" | "wav" | "mp3" | "flac" => Ok(vec![validated_entry(
574 &stem,
575 "AudioClip",
576 serde_json::json!({ "source": path_str }),
577 )?]),
578
579 "hdr" => Ok(vec![environment_map_entry(&stem, path_str)?]),
585
586 "ktx2" => Ok(vec![validated_entry(
590 &base_name,
591 "Texture",
592 serde_json::json!({ "source": path_str }),
593 )?]),
594
595 "obj" | "mtl" | "png" | "jpg" | "jpeg" | "bmp" | "tga" | "gif" => {
597 Ok(vec![validated_entry(
598 &base_name,
599 "File",
600 serde_json::json!({ "path": path_str, "kind": ext.as_str() }),
601 )?])
602 }
603
604 "txt" => Ok(vec![text_label_entry(&stem, path_str)?]),
613
614 "md" => {
618 if md_has_frontmatter(path_str)? {
619 Ok(vec![import_entry("StoryImport", &stem, path_str)?])
620 } else {
621 Ok(vec![text_label_entry(&stem, path_str)?])
622 }
623 }
624
625 "glb" | "gltf" => Ok(vec![scene_or_panorama_entry(&stem, path_str)?]),
633 "fbx" => Ok(vec![import_entry("SceneImport", &stem, path_str)?]),
634
635 other => Err(std::io::Error::new(
636 std::io::ErrorKind::InvalidInput,
637 format!(
638 "unknown extension '.{}'; pass a type name instead \
639 (e.g. `concinnity add Logger`) or edit {} directly",
640 other, WORLD_JSONL
641 ),
642 )),
643 }
644}
645
646fn scene_or_panorama_entry(stem: &str, path_str: &str) -> std::io::Result<serde_json::Value> {
650 if concinnity_cook::panorama::file_is_panorama_sphere(path_str) {
651 tracing::info!(
652 "'{}' is a panorama sphere: importing it as an EnvironmentMap (sky \
653 and image-based lighting) rather than scene geometry",
654 path_str
655 );
656 return environment_map_entry(stem, path_str);
657 }
658 import_entry("SceneImport", stem, path_str)
659}
660
661fn environment_map_entry(stem: &str, path_str: &str) -> std::io::Result<serde_json::Value> {
662 validated_entry(
663 stem,
664 "EnvironmentMap",
665 serde_json::json!({ "source": path_str }),
666 )
667}
668
669fn shader_entries_from_stages(
672 path_str: &str,
673 stem: &str,
674 ext: &str,
675 stages: &[&str],
676) -> std::io::Result<Vec<serde_json::Value>> {
677 if !(stages.contains(&"vertex") && stages.contains(&"fragment")) {
678 return Err(std::io::Error::new(
679 std::io::ErrorKind::InvalidInput,
680 format!(
681 "a Shader needs both a vertex and a fragment stage, but the .{ext} \
682 source declares only {stages:?}. Add the missing stage function, or \
683 declare a Shader entry in world.jsonl with per-stage sources"
684 ),
685 ));
686 }
687 shader_pair_entry(stem, path_str, path_str)
688}
689
690fn shader_pair_entry(
693 stem: &str,
694 vert_path: &str,
695 frag_path: &str,
696) -> std::io::Result<Vec<serde_json::Value>> {
697 Ok(vec![validated_entry(
698 &format!("{stem}_shader"),
699 "Shader",
700 serde_json::json!({
701 "vertex": { "source": vert_path },
702 "fragment": { "source": frag_path },
703 }),
704 )?])
705}
706
707fn sibling_path(path: &std::path::Path, sibling_ext: &str) -> std::io::Result<String> {
711 let sibling = path.with_extension(sibling_ext);
712 if !sibling.exists() {
713 return Err(std::io::Error::new(
714 std::io::ErrorKind::NotFound,
715 format!(
716 "a Shader needs both stages: expected the {} stage next to {} \
717 (looked for {})",
718 if sibling_ext == "vert" {
719 "vertex"
720 } else {
721 "fragment"
722 },
723 path.display(),
724 sibling.display(),
725 ),
726 ));
727 }
728 Ok(sibling.to_string_lossy().into_owned())
729}
730
731fn glsl_pair(
736 path: &std::path::Path,
737 path_str: &str,
738 stem: &str,
739) -> std::io::Result<(String, String, String)> {
740 let (this_marker, other_marker) = if stem.contains("fragment") {
741 ("fragment", "vertex")
742 } else if stem.contains("frag") {
743 ("frag", "vert")
744 } else if stem.contains("vertex") {
745 ("vertex", "fragment")
746 } else if stem.contains("vert") {
747 ("vert", "frag")
748 } else {
749 return Err(std::io::Error::new(
750 std::io::ErrorKind::InvalidInput,
751 format!(
752 "cannot infer the shader stage of {path_str}: name the files with \
753 vert/frag markers (foo_vert.glsl + foo_frag.glsl), or declare a \
754 Shader entry in world.jsonl with per-stage sources"
755 ),
756 ));
757 };
758 let file_name = path
759 .file_name()
760 .and_then(|s| s.to_str())
761 .unwrap_or(path_str);
762 let counterpart = path.with_file_name(file_name.replace(this_marker, other_marker));
763 if !counterpart.exists() {
764 return Err(std::io::Error::new(
765 std::io::ErrorKind::NotFound,
766 format!(
767 "a Shader needs both stages: expected the {other_marker} counterpart \
768 of {path_str} (looked for {})",
769 counterpart.display()
770 ),
771 ));
772 }
773 let counterpart = counterpart.to_string_lossy().into_owned();
774 let this = path_str.to_string();
775 let pair_stem = stem
776 .replace(this_marker, "")
777 .trim_matches('_')
778 .replace("__", "_");
779 let (vert, frag) = if this_marker.starts_with('v') {
780 (this, counterpart)
781 } else {
782 (counterpart, this)
783 };
784 Ok((vert, frag, pair_stem))
785}
786
787pub(crate) fn detect_metal_stages(source: &str) -> Vec<&'static str> {
791 let has_vertex = source.lines().any(|l| {
792 let t = l.trim_start();
793 t.starts_with("vertex ") || t.starts_with("vertex\t")
794 });
795 let has_fragment = source.lines().any(|l| {
796 let t = l.trim_start();
797 t.starts_with("fragment ") || t.starts_with("fragment\t")
798 });
799 stages_from_flags(has_vertex, has_fragment)
800}
801
802pub(crate) fn detect_wgsl_stages(source: &str) -> Vec<&'static str> {
806 stages_from_flags(source.contains("@vertex"), source.contains("@fragment"))
807}
808
809fn stages_from_flags(has_vertex: bool, has_fragment: bool) -> Vec<&'static str> {
810 let mut stages = Vec::new();
811 if has_vertex {
812 stages.push("vertex");
813 }
814 if has_fragment {
815 stages.push("fragment");
816 }
817 if stages.is_empty() {
818 stages.push("vertex");
819 }
820 stages
821}
822
823fn read_source_file(path_str: &str) -> std::io::Result<String> {
824 std::fs::read_to_string(path_str)
825 .map_err(|e| std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e)))
826}
827
828const TEXT_LABEL_MAX_BYTES: usize = 64 * 1024;
832
833fn read_text_content(path_str: &str) -> std::io::Result<String> {
840 let metadata = std::fs::metadata(path_str).map_err(|e| {
841 std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e))
842 })?;
843 if metadata.len() as usize > TEXT_LABEL_MAX_BYTES {
844 return Err(std::io::Error::new(
845 std::io::ErrorKind::InvalidInput,
846 format!(
847 "'{}' is {} bytes; TextLabel content is capped at {} bytes: \
848 trim the file or add it as a `File` asset via inline JSON instead",
849 path_str,
850 metadata.len(),
851 TEXT_LABEL_MAX_BYTES
852 ),
853 ));
854 }
855 let mut content = std::fs::read_to_string(path_str).map_err(|e| {
856 std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e))
857 })?;
858 if content.ends_with('\n') {
859 content.pop();
860 if content.ends_with('\r') {
861 content.pop();
862 }
863 }
864 Ok(content)
865}
866
867fn text_label_entry(name: &str, path_str: &str) -> std::io::Result<serde_json::Value> {
868 validated_entry(
869 name,
870 "TextLabel",
871 serde_json::json!({
872 "content": read_text_content(path_str)?,
873 "centered": true,
874 }),
875 )
876}
877
878fn md_has_frontmatter(path_str: &str) -> std::io::Result<bool> {
882 use std::io::BufRead;
883 let file = std::fs::File::open(path_str).map_err(|e| {
884 std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e))
885 })?;
886 let mut first = String::new();
887 std::io::BufReader::new(file).read_line(&mut first)?;
888 Ok(first.trim_start_matches('\u{feff}').trim_end() == "---")
889}
890
891fn entry_from_json_file(
892 path: &std::path::Path,
893 stem_name: &str,
894) -> std::io::Result<serde_json::Value> {
895 let content = std::fs::read_to_string(path).map_err(|e| {
896 std::io::Error::new(
897 e.kind(),
898 format!("could not read '{}': {}", path.display(), e),
899 )
900 })?;
901 let json: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
902 std::io::Error::new(
903 std::io::ErrorKind::InvalidData,
904 format!("could not parse '{}': {}", path.display(), e),
905 )
906 })?;
907
908 let asset_type = json.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
909 std::io::Error::new(
910 std::io::ErrorKind::InvalidData,
911 format!("'{}' has no `type` field", path.display()),
912 )
913 })?;
914
915 if asset_type.to_lowercase().replace('_', "") == "buildconfig" {
916 return Err(std::io::Error::new(
917 std::io::ErrorKind::InvalidInput,
918 format!(
919 "BuildConfig cannot be added via `concinnity add`; edit {} directly",
920 WORLD_JSONL
921 ),
922 ));
923 }
924
925 let args = json
926 .get("args")
927 .cloned()
928 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
929
930 let name = json
931 .get("name")
932 .and_then(|v| v.as_str())
933 .unwrap_or(stem_name)
934 .to_string();
935
936 validated_entry(&name, asset_type, args)
937}
938
939fn entry_from_inline_json(raw: &str) -> std::io::Result<serde_json::Value> {
940 let json: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
941 std::io::Error::new(
942 std::io::ErrorKind::InvalidData,
943 format!("could not parse inline JSON: {}", e),
944 )
945 })?;
946
947 if !json.is_object() {
948 return Err(std::io::Error::new(
949 std::io::ErrorKind::InvalidData,
950 "inline JSON must be an object (e.g. '{\"type\": \"Window\"}')",
951 ));
952 }
953
954 let asset_type = json.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
955 std::io::Error::new(
956 std::io::ErrorKind::InvalidData,
957 "inline JSON must contain a `type` field",
958 )
959 })?;
960
961 if asset_type.to_lowercase().replace('_', "") == "buildconfig" {
962 return Err(std::io::Error::new(
963 std::io::ErrorKind::InvalidInput,
964 format!(
965 "BuildConfig cannot be added via `concinnity add`; edit {} directly",
966 WORLD_JSONL
967 ),
968 ));
969 }
970
971 let name = json
972 .get("name")
973 .and_then(|v| v.as_str())
974 .map(str::to_string)
975 .unwrap_or_else(|| asset_type.to_lowercase());
976
977 let args = json
978 .get("args")
979 .cloned()
980 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
981
982 let req = AssetRequest {
983 asset_type: asset_type.to_string(),
984 args: Some(args.clone()),
985 };
986 create_asset_def(&req)
987 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
988 let resolved_args = normalized_args_value(asset_type, &args);
989
990 Ok(serde_json::json!({
991 "name": name,
992 "type": asset_type,
993 "args": resolved_args,
994 }))
995}
996
997pub(crate) fn resolve_add_target(target: &str) -> std::io::Result<Vec<serde_json::Value>> {
1001 if is_path_like(target) {
1002 return entry_from_path(target);
1003 }
1004
1005 if RegisteredType::parse(target).is_some() {
1006 return entry_from_type_name(target).map(|e| vec![e]);
1007 }
1008
1009 let as_path = std::path::Path::new(target);
1010 if as_path.exists() && as_path.is_file() {
1011 let name = as_path
1012 .file_name()
1013 .and_then(|s| s.to_str())
1014 .map(|s| s.replace('.', "_"))
1015 .unwrap_or_else(|| target.to_string());
1016 return entry_from_json_file(as_path, &name).map(|e| vec![e]);
1017 }
1018
1019 if target.trim_start().starts_with('{') {
1020 return entry_from_inline_json(target).map(|e| vec![e]);
1021 }
1022
1023 Err(std::io::Error::new(
1024 std::io::ErrorKind::InvalidInput,
1025 format!(
1026 "could not resolve '{}' as any of:\n \
1027 - a file path (no such file found)\n \
1028 - a known asset type (use `concinnity list` to see available types)\n \
1029 - an inline JSON object (must start with '{{' and contain a `type` field)",
1030 target
1031 ),
1032 ))
1033}
1034
1035fn entry_from_type_name(type_str: &str) -> std::io::Result<serde_json::Value> {
1036 if type_str.to_lowercase().replace('_', "") == "buildconfig" {
1037 return Err(std::io::Error::new(
1038 std::io::ErrorKind::InvalidInput,
1039 format!(
1040 "BuildConfig cannot be added via `concinnity add`; edit {} directly",
1041 WORLD_JSONL
1042 ),
1043 ));
1044 }
1045
1046 let req = AssetRequest {
1047 asset_type: type_str.to_string(),
1048 args: None,
1049 };
1050 create_asset_def(&req)
1051 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
1052 let args = normalized_args_value(type_str, &serde_json::Value::Object(Default::default()));
1053
1054 let name = type_str.to_lowercase();
1055
1056 Ok(serde_json::json!({
1057 "name": name,
1058 "type": type_str,
1059 "args": args,
1060 }))
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065 use super::*;
1066
1067 #[test]
1070 fn metal_both_stages() {
1071 let src = "vertex VertexOut vert_main() {}\nfragment float4 frag_main() {}";
1072 assert_eq!(detect_metal_stages(src), vec!["vertex", "fragment"]);
1073 }
1074
1075 #[test]
1076 fn metal_vertex_only() {
1077 let src = "vertex VertexOut vert_main() {}";
1078 assert_eq!(detect_metal_stages(src), vec!["vertex"]);
1079 }
1080
1081 #[test]
1082 fn metal_fragment_only() {
1083 let src = "fragment float4 frag_main() {}";
1084 assert_eq!(detect_metal_stages(src), vec!["fragment"]);
1085 }
1086
1087 #[test]
1088 fn metal_no_qualifiers_defaults_to_vertex() {
1089 let src = "// helper only\nfloat4 helper() { return float4(1.0); }";
1090 assert_eq!(detect_metal_stages(src), vec!["vertex"]);
1091 }
1092
1093 #[test]
1094 fn metal_tab_separated_qualifier() {
1095 let src = "vertex\tVertexOut vert_main() {}";
1096 assert_eq!(detect_metal_stages(src), vec!["vertex"]);
1097 }
1098
1099 #[test]
1100 fn metal_indented_qualifier_still_detected() {
1101 let src = " vertex VertexOut vert_main() {}\n fragment float4 frag_main() {}";
1103 assert_eq!(detect_metal_stages(src), vec!["vertex", "fragment"]);
1104 }
1105
1106 #[test]
1109 fn wgsl_both_stages() {
1110 let src =
1111 "@vertex\nfn vs() -> VertexOutput {}\n@fragment\nfn fs() -> @location(0) vec4<f32> {}";
1112 assert_eq!(detect_wgsl_stages(src), vec!["vertex", "fragment"]);
1113 }
1114
1115 #[test]
1116 fn wgsl_vertex_only() {
1117 let src = "@vertex fn vs() {}";
1118 assert_eq!(detect_wgsl_stages(src), vec!["vertex"]);
1119 }
1120
1121 #[test]
1122 fn wgsl_no_attributes_defaults_to_vertex() {
1123 let src = "fn helper() -> f32 { return 1.0; }";
1124 assert_eq!(detect_wgsl_stages(src), vec!["vertex"]);
1125 }
1126
1127 #[test]
1128 fn a_two_stage_source_becomes_one_shader_entry() {
1129 let entries =
1130 shader_entries_from_stages("s.metal", "s", "metal", &["vertex", "fragment"]).unwrap();
1131 assert_eq!(entries.len(), 1);
1132 assert_eq!(entries[0]["name"], "s_shader");
1133 assert_eq!(entries[0]["type"], "Shader");
1134 assert_eq!(entries[0]["args"]["vertex"]["source"], "s.metal");
1135 assert_eq!(entries[0]["args"]["fragment"]["source"], "s.metal");
1136 }
1137
1138 #[test]
1139 fn a_single_stage_source_is_rejected_with_guidance() {
1140 let err = shader_entries_from_stages("s.metal", "s", "metal", &["vertex"]).unwrap_err();
1141 assert!(err.to_string().contains("both a vertex and a fragment"));
1142 }
1143
1144 #[test]
1145 fn glsl_pair_swaps_the_stage_marker_and_shares_a_stem() {
1146 let dir = tempfile::tempdir().unwrap();
1147 let vert = dir.path().join("foo_vert.glsl");
1148 let frag = dir.path().join("foo_frag.glsl");
1149 std::fs::write(&vert, "").unwrap();
1150 std::fs::write(&frag, "").unwrap();
1151
1152 let from_vert = glsl_pair(&vert, vert.to_str().unwrap(), "foo_vert").unwrap();
1154 let from_frag = glsl_pair(&frag, frag.to_str().unwrap(), "foo_frag").unwrap();
1155 assert_eq!(from_vert.0, vert.to_str().unwrap());
1156 assert_eq!(from_vert.1, frag.to_str().unwrap());
1157 assert_eq!(from_vert.0, from_frag.0);
1158 assert_eq!(from_vert.1, from_frag.1);
1159 assert_eq!(from_vert.2, "foo");
1160 assert_eq!(from_frag.2, "foo");
1161
1162 std::fs::remove_file(&frag).unwrap();
1164 assert!(glsl_pair(&vert, vert.to_str().unwrap(), "foo_vert").is_err());
1165 }
1166
1167 #[test]
1170 fn font_name_uses_stem_only() {
1171 let path = std::path::Path::new("fonts/JetBrainsMono-Regular.ttf");
1172 let stem = path
1173 .file_stem()
1174 .and_then(|s| s.to_str())
1175 .map(|s| s.replace('.', "_"))
1176 .unwrap_or_default();
1177 assert_eq!(stem, "JetBrainsMono-Regular");
1179 assert!(!stem.contains("ttf"));
1180 }
1181
1182 #[test]
1185 fn unknown_extension_errors() {
1186 let result = entry_from_path("assets/shader.xyz");
1188 assert!(result.is_err());
1189 let msg = result.unwrap_err().to_string();
1190 assert!(msg.contains(".xyz"));
1191 }
1192
1193 #[test]
1196 fn stages_from_flags_neither() {
1197 assert_eq!(stages_from_flags(false, false), vec!["vertex"]);
1198 }
1199
1200 #[test]
1201 fn stages_from_flags_both() {
1202 assert_eq!(stages_from_flags(true, true), vec!["vertex", "fragment"]);
1203 }
1204
1205 #[test]
1206 fn stages_from_flags_fragment_only() {
1207 assert_eq!(stages_from_flags(false, true), vec!["fragment"]);
1208 }
1209
1210 #[test]
1213 fn target_bootstrap_kind_glb_is_scene() {
1214 assert!(matches!(
1215 target_bootstrap_kind("models/scene.glb"),
1216 BootstrapKind::Scene
1217 ));
1218 assert!(matches!(
1219 target_bootstrap_kind("scene.GLB"),
1220 BootstrapKind::Scene
1221 ));
1222 }
1223
1224 #[test]
1225 fn target_bootstrap_kind_text_is_text() {
1226 assert!(matches!(
1227 target_bootstrap_kind("notes.txt"),
1228 BootstrapKind::Text
1229 ));
1230 assert!(matches!(
1231 target_bootstrap_kind("README.MD"),
1232 BootstrapKind::Text
1233 ));
1234 }
1235
1236 #[test]
1237 fn target_bootstrap_kind_other_is_none() {
1238 assert!(matches!(
1239 target_bootstrap_kind("Logger"),
1240 BootstrapKind::None
1241 ));
1242 assert!(matches!(
1243 target_bootstrap_kind("shader.vert"),
1244 BootstrapKind::None
1245 ));
1246 assert!(matches!(
1247 target_bootstrap_kind("font.ttf"),
1248 BootstrapKind::None
1249 ));
1250 }
1251
1252 #[test]
1255 fn renderer_trigger_matches_graphics_config() {
1256 let jsonl = r#"{"name":"gc","type":"GraphicsConfig","args":{}}"#;
1257 assert!(jsonl_has_renderer_trigger(jsonl));
1258 }
1259
1260 #[test]
1261 fn renderer_trigger_matches_text_label() {
1262 let jsonl = r#"{"name":"lbl","type":"TextLabel","args":{}}"#;
1263 assert!(jsonl_has_renderer_trigger(jsonl));
1264 }
1265
1266 #[test]
1269 fn renderer_trigger_matches_prop() {
1270 let jsonl = r#"{"name":"crate","type":"Prop","args":{}}"#;
1271 assert!(jsonl_has_renderer_trigger(jsonl));
1272 }
1273
1274 #[test]
1277 fn renderer_trigger_ignores_window() {
1278 let jsonl = r#"{"name":"win","type":"Window","args":{}}"#;
1279 assert!(!jsonl_has_renderer_trigger(jsonl));
1280 }
1281
1282 #[test]
1283 fn renderer_trigger_absent_for_render_data_only_world() {
1284 let jsonl = concat!(
1287 r#"{"name":"tex","type":"Texture","args":{}}"#,
1288 "\n",
1289 r#"{"name":"mat","type":"Material","args":{}}"#,
1290 "\n",
1291 r#"{"name":"mesh","type":"Mesh","args":{}}"#,
1292 "\n",
1293 r#"{"name":"model","type":"Model","args":{}}"#,
1294 "\n",
1295 r#"{"name":"cam","type":"Camera3D","args":{}}"#,
1296 "\n",
1297 );
1298 assert!(!jsonl_has_renderer_trigger(jsonl));
1299 }
1300
1301 #[test]
1302 fn renderer_trigger_skips_malformed_lines() {
1303 let jsonl = concat!(
1304 "garbage line\n",
1305 r#"{"name":"tex","type":"Texture","args":{}}"#,
1306 "\n",
1307 );
1308 assert!(!jsonl_has_renderer_trigger(jsonl));
1309 }
1310
1311 #[test]
1312 fn renderer_trigger_handles_empty_jsonl() {
1313 assert!(!jsonl_has_renderer_trigger(""));
1314 }
1315
1316 #[test]
1319 fn scaffold_to_inject_writes_nothing_without_a_template() {
1320 let dir =
1323 std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1324 std::fs::create_dir_all(&dir).unwrap();
1325 let world = dir.join("world.jsonl");
1326 std::fs::write(
1327 &world,
1328 concat!(
1329 r#"{"name":"tex","type":"Texture","args":{}}"#,
1330 "\n",
1331 r#"{"name":"cam","type":"Camera3D","args":{}}"#,
1332 "\n",
1333 ),
1334 )
1335 .unwrap();
1336
1337 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", None).unwrap();
1338 assert!(scaffold.is_empty());
1339
1340 let _ = std::fs::remove_dir_all(&dir);
1341 }
1342
1343 #[test]
1344 fn scaffold_to_inject_returns_empty_when_world_has_graphics_config() {
1345 let dir =
1346 std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1347 std::fs::create_dir_all(&dir).unwrap();
1348 let world = dir.join("world.jsonl");
1349 std::fs::write(
1350 &world,
1351 r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
1352 )
1353 .unwrap();
1354
1355 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", None).unwrap();
1356 assert!(scaffold.is_empty());
1357
1358 let _ = std::fs::remove_dir_all(&dir);
1359 }
1360
1361 #[test]
1362 fn scaffold_to_inject_allows_missing_world_with_glb() {
1363 let dir =
1367 std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1368 let _ = std::fs::remove_dir_all(&dir);
1369 let world = dir.join("world.jsonl");
1370
1371 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", None).unwrap();
1372 assert!(scaffold.is_empty());
1373 }
1374
1375 #[test]
1376 fn scaffold_to_inject_errors_for_missing_world_with_non_scene_target() {
1377 let dir =
1378 std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1379 let _ = std::fs::remove_dir_all(&dir);
1380 let world = dir.join("world.jsonl");
1381
1382 let err = scaffold_to_inject(world.to_str().unwrap(), "Logger", None)
1383 .expect_err("missing world + non-scene target must error");
1384 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1385 }
1386
1387 #[test]
1388 fn scaffold_to_inject_returns_empty_for_non_scene_target_into_existing_world() {
1389 let dir =
1390 std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1391 std::fs::create_dir_all(&dir).unwrap();
1392 let world = dir.join("world.jsonl");
1393 std::fs::write(&world, "").unwrap();
1394
1395 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "Logger", None).unwrap();
1398 assert!(scaffold.is_empty());
1399
1400 let _ = std::fs::remove_dir_all(&dir);
1401 }
1402
1403 #[test]
1406 fn scaffold_to_inject_uses_named_template() {
1407 let dir =
1408 std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1409 let _ = std::fs::remove_dir_all(&dir);
1410 let world = dir.join("world.jsonl");
1411
1412 let name = concinnity_world::template::TEMPLATES[0].name;
1413 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", Some(name))
1414 .expect("a named template should apply for a renderer-less glb add");
1415 let expected = concinnity_world::template::by_name(name)
1416 .unwrap()
1417 .assets()
1418 .len();
1419 assert_eq!(scaffold.len(), expected, "expected the template's entries");
1420 assert!(!scaffold.is_empty());
1421 }
1422
1423 #[test]
1429 fn every_template_entry_validates_as_a_real_asset() {
1430 let _guard = crate::test_support::lock();
1431 for t in concinnity_world::template::TEMPLATES {
1432 let entries = crate::authoring::template_spec::world_template_entries(t);
1433 assert!(!entries.is_empty(), "template '{}' is empty", t.name);
1434 for entry in entries {
1435 let ty = entry["type"].as_str().expect("entry has a type");
1436 let name = entry["name"].as_str().expect("entry has a name");
1437 let args = entry
1438 .get("args")
1439 .cloned()
1440 .unwrap_or_else(|| serde_json::json!({}));
1441 concinnity_cook::validate_asset(ty, name, &args).unwrap_or_else(|e| {
1442 panic!(
1443 "template '{}' entry '{name}' ({ty}) failed to validate: {e}",
1444 t.name
1445 )
1446 });
1447 }
1448 }
1449 }
1450
1451 #[test]
1452 fn scaffold_to_inject_errors_on_unknown_template() {
1453 let dir =
1454 std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1455 let _ = std::fs::remove_dir_all(&dir);
1456 let world = dir.join("world.jsonl");
1457
1458 let err = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", Some("nope"))
1459 .expect_err("unknown template name must surface as InvalidInput");
1460 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1461 assert!(
1462 err.to_string().contains("nope"),
1463 "error should name the bad template: {err}"
1464 );
1465 }
1466
1467 #[test]
1468 fn scaffold_to_inject_rejects_unknown_template_even_when_no_scaffold_would_fire() {
1469 let dir =
1473 std::env::temp_dir().join(format!("cn_add_test_{}_{}", std::process::id(), line!()));
1474 std::fs::create_dir_all(&dir).unwrap();
1475 let world = dir.join("world.jsonl");
1476 std::fs::write(
1478 &world,
1479 r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
1480 )
1481 .unwrap();
1482
1483 let err = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", Some("nope"))
1484 .expect_err("unknown template should fail fast regardless of scaffold path");
1485 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1486
1487 let _ = std::fs::remove_dir_all(&dir);
1488 }
1489
1490 fn text_test_dir(line: u32) -> std::path::PathBuf {
1493 let dir =
1494 std::env::temp_dir().join(format!("cn_add_text_test_{}_{}", std::process::id(), line));
1495 let _ = std::fs::remove_dir_all(&dir);
1496 std::fs::create_dir_all(&dir).unwrap();
1497 dir
1498 }
1499
1500 #[test]
1501 fn text_file_becomes_text_label_with_content() {
1502 let dir = text_test_dir(line!());
1503 let path = dir.join("greeting.txt");
1504 std::fs::write(&path, "Hello, world!").unwrap();
1505
1506 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1507 assert_eq!(entries.len(), 1);
1508 let entry = &entries[0];
1509 assert_eq!(entry["type"], "TextLabel");
1510 assert_eq!(entry["name"], "greeting");
1511 assert_eq!(entry["args"]["content"], "Hello, world!");
1512 assert_eq!(entry["args"]["centered"], serde_json::json!(true));
1514
1515 let _ = std::fs::remove_dir_all(&dir);
1516 }
1517
1518 #[test]
1519 fn text_file_strips_single_trailing_newline() {
1520 let dir = text_test_dir(line!());
1521 let lf = dir.join("lf.txt");
1522 std::fs::write(&lf, "line one\nline two\n").unwrap();
1523 let crlf = dir.join("crlf.md");
1524 std::fs::write(&crlf, "line one\r\nline two\r\n").unwrap();
1525
1526 let lf_entry = &entry_from_path(lf.to_str().unwrap()).unwrap()[0];
1527 assert_eq!(lf_entry["args"]["content"], "line one\nline two");
1528 let crlf_entry = &entry_from_path(crlf.to_str().unwrap()).unwrap()[0];
1529 assert_eq!(crlf_entry["args"]["content"], "line one\r\nline two");
1530
1531 let _ = std::fs::remove_dir_all(&dir);
1532 }
1533
1534 #[test]
1535 fn audio_file_becomes_audio_clip() {
1536 let dir = text_test_dir(line!());
1537 let path = dir.join("door_creak.wav");
1538 std::fs::write(&path, b"not-really-audio").unwrap();
1539
1540 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1541 assert_eq!(entries.len(), 1);
1542 let entry = &entries[0];
1543 assert_eq!(entry["type"], "AudioClip");
1544 assert_eq!(entry["name"], "door_creak");
1545 assert_eq!(entry["args"]["source"], path.to_str().unwrap());
1546
1547 let _ = std::fs::remove_dir_all(&dir);
1548 }
1549
1550 #[test]
1551 fn hdr_file_becomes_an_environment_map() {
1552 let dir = text_test_dir(line!());
1553 let path = dir.join("san_giuseppe_4k.hdr");
1554 std::fs::write(&path, b"not-really-radiance").unwrap();
1555
1556 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1557 assert_eq!(entries.len(), 1);
1558 let entry = &entries[0];
1559 assert_eq!(entry["type"], "EnvironmentMap");
1560 assert_eq!(entry["name"], "san_giuseppe_4k");
1561 assert_eq!(entry["args"]["source"], path.to_str().unwrap());
1562 assert_eq!(entry["args"]["generator"], "");
1565 assert_eq!(entry["args"]["prefilter_face_size"], serde_json::json!(512));
1567
1568 let _ = std::fs::remove_dir_all(&dir);
1569 }
1570
1571 #[test]
1573 fn an_hdr_entry_validates_against_the_environment_map_schema() {
1574 let _guard = crate::test_support::lock();
1575 let dir = text_test_dir(line!());
1576 let path = dir.join("studio.hdr");
1577 std::fs::write(&path, b"radiance").unwrap();
1578
1579 let entry = entry_from_path(path.to_str().unwrap()).unwrap().remove(0);
1580 concinnity_cook::validate_asset("EnvironmentMap", "studio", &entry["args"])
1581 .expect("a `.hdr` add must produce a cookable EnvironmentMap");
1582
1583 let _ = std::fs::remove_dir_all(&dir);
1584 }
1585
1586 #[test]
1592 fn try_retarget_environment_map_repoints_the_existing_map() {
1593 let mut assets = vec![serde_json::json!({
1594 "name": "env_sky",
1595 "type": "EnvironmentMap",
1596 "args": {
1597 "source": "assets/hdri/old.hdr",
1598 "generator": "",
1599 "prefilter_face_size": 1024,
1600 "prefilter_clamp": 4.0,
1601 }
1602 })];
1603 let entries = vec![serde_json::json!({
1604 "name": "studio",
1605 "type": "EnvironmentMap",
1606 "args": {"source": "assets/hdri/studio.hdr", "generator": ""}
1607 })];
1608
1609 let out = try_retarget_environment_map(&mut assets, &entries);
1610 assert_eq!(
1611 out,
1612 Some(("env_sky".to_string(), "assets/hdri/studio.hdr".to_string()))
1613 );
1614 assert_eq!(assets.len(), 1, "no second map appended");
1615 let args = assets[0]["args"].as_object().unwrap();
1616 assert_eq!(args["source"], "assets/hdri/studio.hdr");
1617 assert_eq!(args["prefilter_face_size"], 1024);
1619 assert_eq!(args["prefilter_clamp"], 4.0);
1620 assert_eq!(assets[0]["name"], "env_sky");
1622 }
1623
1624 #[test]
1627 fn try_retarget_environment_map_clears_a_generator() {
1628 let mut assets = vec![serde_json::json!({
1629 "name": "env",
1630 "type": "EnvironmentMap",
1631 "args": {"source": "", "generator": "sky"}
1632 })];
1633 let entries = vec![serde_json::json!({
1634 "name": "dusk",
1635 "type": "EnvironmentMap",
1636 "args": {"source": "dusk.hdr", "generator": ""}
1637 })];
1638
1639 assert!(try_retarget_environment_map(&mut assets, &entries).is_some());
1640 let args = assets[0]["args"].as_object().unwrap();
1641 assert_eq!(args["source"], "dusk.hdr");
1642 assert_eq!(args["generator"], "");
1643 }
1644
1645 #[test]
1647 fn try_retarget_environment_map_takes_the_first_of_several() {
1648 let mut assets = vec![
1649 serde_json::json!({"name": "a", "type": "EnvironmentMap", "args": {"source": "a.hdr"}}),
1650 serde_json::json!({"name": "b", "type": "EnvironmentMap", "args": {"source": "b.hdr"}}),
1651 ];
1652 let entries = vec![
1653 serde_json::json!({"name": "c", "type": "EnvironmentMap", "args": {"source": "c.hdr"}}),
1654 ];
1655
1656 let (name, _) = try_retarget_environment_map(&mut assets, &entries).unwrap();
1657 assert_eq!(name, "a");
1658 assert_eq!(assets[0]["args"]["source"], "c.hdr");
1659 assert_eq!(
1660 assets[1]["args"]["source"], "b.hdr",
1661 "the rest are untouched"
1662 );
1663 }
1664
1665 #[test]
1667 fn try_retarget_environment_map_skips_a_world_without_one() {
1668 let mut assets = vec![serde_json::json!({
1669 "name": "lamp", "type": "PointLight", "args": {}
1670 })];
1671 let entries = vec![
1672 serde_json::json!({"name": "env", "type": "EnvironmentMap", "args": {"source": "e.hdr"}}),
1673 ];
1674 assert!(try_retarget_environment_map(&mut assets, &entries).is_none());
1675 }
1676
1677 #[test]
1678 fn try_retarget_environment_map_skips_other_types() {
1679 let mut assets = vec![serde_json::json!({
1680 "name": "env", "type": "EnvironmentMap", "args": {"source": "e.hdr"}
1681 })];
1682 let entries =
1683 vec![serde_json::json!({"name": "face", "type": "Font", "args": {"path": "face.ttf"}})];
1684 assert!(try_retarget_environment_map(&mut assets, &entries).is_none());
1685 assert_eq!(assets[0]["args"]["source"], "e.hdr");
1686 }
1687
1688 #[test]
1690 fn try_retarget_environment_map_skips_multi_entry() {
1691 let mut assets = vec![serde_json::json!({
1692 "name": "env", "type": "EnvironmentMap", "args": {"source": "e.hdr"}
1693 })];
1694 let entries = vec![
1695 serde_json::json!({"name": "a", "type": "EnvironmentMap", "args": {"source": "a.hdr"}}),
1696 serde_json::json!({"name": "b", "type": "EnvironmentMap", "args": {"source": "b.hdr"}}),
1697 ];
1698 assert!(try_retarget_environment_map(&mut assets, &entries).is_none());
1699 }
1700
1701 #[test]
1702 fn markdown_with_frontmatter_becomes_story_import() {
1703 let dir = text_test_dir(line!());
1704 let path = dir.join("crossroads.md");
1705 std::fs::write(&path, "---\ntitle: T\n---\n\n# a\n\nhi\n").unwrap();
1706
1707 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1708 assert_eq!(entries.len(), 1);
1709 let entry = &entries[0];
1710 assert_eq!(entry["type"], "StoryImport");
1711 assert_eq!(entry["name"], "crossroads");
1712 assert_eq!(entry["args"]["source"], path.to_str().unwrap());
1713 assert_eq!(entry["args"]["title_screen"], serde_json::json!(true));
1715
1716 let _ = std::fs::remove_dir_all(&dir);
1717 }
1718
1719 #[test]
1720 fn markdown_without_frontmatter_stays_a_text_label() {
1721 let dir = text_test_dir(line!());
1722 let path = dir.join("notes.md");
1723 std::fs::write(&path, "# Notes\n\nplain markdown\n").unwrap();
1724
1725 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1726 assert_eq!(entries.len(), 1);
1727 assert_eq!(entries[0]["type"], "TextLabel");
1728
1729 let _ = std::fs::remove_dir_all(&dir);
1730 }
1731
1732 #[test]
1733 fn text_file_over_size_cap_errors() {
1734 let dir = text_test_dir(line!());
1735 let path = dir.join("huge.txt");
1736 let blob = "a".repeat(TEXT_LABEL_MAX_BYTES + 1);
1737 std::fs::write(&path, blob).unwrap();
1738
1739 let err = entry_from_path(path.to_str().unwrap())
1740 .expect_err("oversized text file must fail rather than silently truncate");
1741 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1742 assert!(err.to_string().contains("capped"));
1743
1744 let _ = std::fs::remove_dir_all(&dir);
1745 }
1746
1747 #[test]
1748 fn scaffold_to_inject_empty_for_missing_world_with_text_target() {
1749 let dir = text_test_dir(line!());
1752 let world = dir.join("world.jsonl");
1753
1754 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "notes.txt", None).unwrap();
1755 assert!(scaffold.is_empty(), "text target should emit no scaffold");
1756
1757 let _ = std::fs::remove_dir_all(&dir);
1758 }
1759
1760 #[test]
1761 fn scaffold_to_inject_empty_for_renderer_less_world_with_text_target() {
1762 let dir = text_test_dir(line!());
1763 let world = dir.join("world.jsonl");
1764 std::fs::write(&world, r#"{"name":"tex","type":"Texture","args":{}}"#).unwrap();
1765
1766 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "notes.md", None).unwrap();
1767 assert!(
1768 scaffold.is_empty(),
1769 "text target never injects GLB scaffold"
1770 );
1771
1772 let _ = std::fs::remove_dir_all(&dir);
1773 }
1774
1775 #[test]
1778 fn try_refresh_text_label_updates_content_in_place() {
1779 let mut assets = vec![serde_json::json!({
1782 "name": "greeting",
1783 "type": "TextLabel",
1784 "args": {
1785 "content": "Old text",
1786 "font": "Questrial-Regular",
1787 "x": 10.0,
1788 "y": 200.0,
1789 "color": [0.2, 0.8, 0.4],
1790 "scale": 1.5,
1791 "centered": false,
1792 "background": [0.0, 0.0, 0.0, 0.0],
1793 "padding": 0.0,
1794 "visible": true,
1795 "screen": null,
1796 }
1797 })];
1798 let entries = vec![serde_json::json!({
1799 "name": "greeting",
1800 "type": "TextLabel",
1801 "args": {
1802 "content": "New text",
1803 "centered": true,
1804 }
1805 })];
1806
1807 let refreshed = try_refresh_text_label(&mut assets, &entries);
1808 assert_eq!(refreshed.as_deref(), Some("greeting"));
1809 let args = assets[0]["args"].as_object().unwrap();
1810 assert_eq!(args["content"], "New text");
1811 assert_eq!(args["y"], 200.0);
1813 assert_eq!(args["color"], serde_json::json!([0.2, 0.8, 0.4]));
1814 assert_eq!(args["scale"], 1.5);
1815 assert_eq!(args["centered"], false);
1816 }
1817
1818 #[test]
1819 fn try_refresh_text_label_skips_non_textlabel_new_entry() {
1820 let mut assets = vec![serde_json::json!({
1821 "name": "thing",
1822 "type": "TextLabel",
1823 "args": {"content": "old"}
1824 })];
1825 let entries = vec![serde_json::json!({
1826 "name": "thing",
1827 "type": "Font",
1828 "args": {"path": "x.ttf"}
1829 })];
1830 assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1831 }
1832
1833 #[test]
1834 fn try_refresh_text_label_skips_when_existing_is_different_type() {
1835 let mut assets = vec![serde_json::json!({
1836 "name": "thing",
1837 "type": "Font",
1838 "args": {"path": "x.ttf"}
1839 })];
1840 let entries = vec![serde_json::json!({
1841 "name": "thing",
1842 "type": "TextLabel",
1843 "args": {"content": "hi"}
1844 })];
1845 assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1848 }
1849
1850 #[test]
1851 fn try_refresh_text_label_skips_when_name_misses() {
1852 let mut assets = vec![serde_json::json!({
1853 "name": "greeting",
1854 "type": "TextLabel",
1855 "args": {"content": "old"}
1856 })];
1857 let entries = vec![serde_json::json!({
1858 "name": "caption",
1859 "type": "TextLabel",
1860 "args": {"content": "new"}
1861 })];
1862 assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1863 }
1864
1865 #[test]
1866 fn try_refresh_text_label_skips_multi_entry() {
1867 let mut assets = vec![serde_json::json!({
1870 "name": "label",
1871 "type": "TextLabel",
1872 "args": {"content": "old"}
1873 })];
1874 let entries = vec![
1875 serde_json::json!({"name": "label", "type": "TextLabel", "args": {"content": "new"}}),
1876 serde_json::json!({"name": "other", "type": "TextLabel", "args": {"content": "other"}}),
1877 ];
1878 assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1879 }
1880
1881 #[test]
1882 fn scaffold_to_inject_rejects_template_with_text_target() {
1883 let dir = text_test_dir(line!());
1884 let world = dir.join("world.jsonl");
1885
1886 let name = concinnity_world::template::TEMPLATES[0].name;
1887 let err = scaffold_to_inject(world.to_str().unwrap(), "notes.txt", Some(name))
1888 .expect_err("--template should only apply to scene targets");
1889 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1890
1891 let _ = std::fs::remove_dir_all(&dir);
1892 }
1893
1894 #[test]
1897 fn inline_json_must_be_an_object() {
1898 let err = entry_from_inline_json("[1, 2]").unwrap_err();
1899 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1900 assert!(err.to_string().contains("must be an object"), "got: {err}");
1901 }
1902
1903 #[test]
1904 fn inline_json_requires_a_type_field() {
1905 let err = entry_from_inline_json(r#"{"name":"thing"}"#).unwrap_err();
1906 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1907 assert!(err.to_string().contains("`type` field"), "got: {err}");
1908 }
1909
1910 #[test]
1911 fn inline_json_rejects_malformed_text() {
1912 let err = entry_from_inline_json("{ nope").unwrap_err();
1913 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1914 }
1915
1916 #[test]
1917 fn inline_json_rejects_build_config() {
1918 let err = entry_from_inline_json(r#"{"type":"BuildConfig"}"#).unwrap_err();
1919 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1920 let err = entry_from_inline_json(r#"{"type":"build_config"}"#).unwrap_err();
1922 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1923 }
1924
1925 #[test]
1926 fn inline_json_defaults_the_name_to_the_lowercased_type() {
1927 let entry = entry_from_inline_json(r#"{"type":"Window"}"#).unwrap();
1928 assert_eq!(entry["name"], "window");
1929 assert_eq!(entry["type"], "Window");
1930 assert!(entry["args"].is_object());
1932 }
1933
1934 #[test]
1935 fn inline_json_keeps_an_explicit_name() {
1936 let entry = entry_from_inline_json(r#"{"type":"Window","name":"main"}"#).unwrap();
1937 assert_eq!(entry["name"], "main");
1938 }
1939
1940 #[test]
1943 fn type_name_builds_a_default_entry() {
1944 let entry = entry_from_type_name("Window").unwrap();
1945 assert_eq!(entry["name"], "window");
1946 assert_eq!(entry["type"], "Window");
1947 assert!(entry["args"].is_object());
1948 }
1949
1950 #[test]
1951 fn type_name_rejects_build_config() {
1952 let err = entry_from_type_name("BuildConfig").unwrap_err();
1953 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1954 }
1955
1956 #[test]
1957 fn type_name_rejects_an_unknown_type() {
1958 assert!(entry_from_type_name("NotARealAssetType").is_err());
1959 }
1960
1961 #[test]
1964 fn json_file_requires_a_type_field() {
1965 let dir = text_test_dir(line!());
1966 let path = dir.join("thing.json");
1967 std::fs::write(&path, r#"{"name":"thing"}"#).unwrap();
1968
1969 let err = entry_from_json_file(&path, "thing").unwrap_err();
1970 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1971 assert!(err.to_string().contains("no `type` field"), "got: {err}");
1972
1973 let _ = std::fs::remove_dir_all(&dir);
1974 }
1975
1976 #[test]
1977 fn json_file_rejects_build_config() {
1978 let dir = text_test_dir(line!());
1979 let path = dir.join("cfg.json");
1980 std::fs::write(&path, r#"{"type":"BuildConfig"}"#).unwrap();
1981
1982 let err = entry_from_json_file(&path, "cfg").unwrap_err();
1983 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1984
1985 let _ = std::fs::remove_dir_all(&dir);
1986 }
1987
1988 #[test]
1989 fn json_file_uses_the_stem_when_unnamed() {
1990 let dir = text_test_dir(line!());
1991 let path = dir.join("main_window.json");
1992 std::fs::write(&path, r#"{"type":"Window","args":{}}"#).unwrap();
1993
1994 let entry = entry_from_json_file(&path, "main_window").unwrap();
1995 assert_eq!(entry["name"], "main_window");
1996 assert_eq!(entry["type"], "Window");
1997
1998 let _ = std::fs::remove_dir_all(&dir);
1999 }
2000
2001 #[test]
2002 fn json_file_read_and_parse_failures_are_reported() {
2003 let dir = text_test_dir(line!());
2004 let missing = dir.join("missing.json");
2006 assert!(entry_from_json_file(&missing, "missing").is_err());
2007 let junk = dir.join("junk.json");
2009 std::fs::write(&junk, "{ nope").unwrap();
2010 let err = entry_from_json_file(&junk, "junk").unwrap_err();
2011 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2012
2013 let _ = std::fs::remove_dir_all(&dir);
2014 }
2015
2016 #[test]
2019 fn resolve_add_target_dispatches_type_names_and_inline_json() {
2020 let entries = resolve_add_target("Window").unwrap();
2021 assert_eq!(entries.len(), 1);
2022 assert_eq!(entries[0]["type"], "Window");
2023
2024 let entries = resolve_add_target(r#"{"type":"Window","name":"main"}"#).unwrap();
2025 assert_eq!(entries.len(), 1);
2026 assert_eq!(entries[0]["name"], "main");
2027 }
2028
2029 #[test]
2030 fn resolve_add_target_reports_an_unresolvable_target() {
2031 let err = resolve_add_target("DefinitelyNotAnAssetOrFile").unwrap_err();
2032 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
2033 assert!(err.to_string().contains("could not resolve"), "got: {err}");
2034 }
2035
2036 #[test]
2040 fn import_extension_groups_track_the_dispatch() {
2041 let dir = tempfile::tempdir().unwrap();
2042 for (_, exts) in IMPORT_EXTENSION_GROUPS {
2043 for ext in *exts {
2044 let path = dir.path().join(format!("probe.{ext}"));
2045 std::fs::write(&path, b"{}").unwrap();
2047 let path_str = path.to_string_lossy();
2048 if let Err(e) = entry_from_path(&path_str) {
2049 assert!(
2050 !e.to_string().contains("unknown extension"),
2051 ".{ext} is advertised by the picker but not dispatched: {e}"
2052 );
2053 }
2054 }
2055 }
2056 }
2057
2058 #[test]
2060 fn import_extension_groups_are_non_empty_and_unique() {
2061 let mut seen = std::collections::HashSet::new();
2062 for (name, exts) in IMPORT_EXTENSION_GROUPS {
2063 assert!(!exts.is_empty(), "{name} has no extensions");
2064 for ext in *exts {
2065 assert!(seen.insert(*ext), "'{ext}' listed in two groups");
2066 assert!(
2067 !ext.starts_with('.'),
2068 "'{ext}' should be bare (rfd adds the dot)"
2069 );
2070 }
2071 }
2072 }
2073
2074 #[test]
2077 fn import_entry_sets_the_source_on_registration_defaults() {
2078 let entry = import_entry("SceneImport", "bistro", "scenes/bistro.fbx").unwrap();
2079 assert_eq!(entry["name"], "bistro");
2080 assert_eq!(entry["type"], "SceneImport");
2081 assert_eq!(entry["args"]["source"], "scenes/bistro.fbx");
2082 }
2083
2084 #[test]
2085 fn import_entry_rejects_an_unknown_type() {
2086 assert!(import_entry("NotARealAssetType", "x", "x.glb").is_err());
2087 }
2088
2089 #[test]
2096 fn a_glb_that_is_not_a_panorama_imports_as_geometry() {
2097 let dir = text_test_dir(line!());
2098 let path = dir.join("scene.glb");
2099 std::fs::write(&path, b"not a panorama").unwrap();
2100
2101 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
2102 assert_eq!(entries.len(), 1);
2103 assert_eq!(entries[0]["type"], "SceneImport");
2104 assert_eq!(entries[0]["args"]["source"], path.to_str().unwrap());
2105 }
2106
2107 #[test]
2108 fn a_panorama_becomes_an_environment_map_naming_its_source() {
2109 let entry = environment_map_entry("galaxy", "assets/hdri/galaxy.glb").unwrap();
2110 assert_eq!(entry["name"], "galaxy");
2111 assert_eq!(entry["type"], "EnvironmentMap");
2112 assert_eq!(entry["args"]["source"], "assets/hdri/galaxy.glb");
2113 }
2114
2115 #[test]
2116 fn an_hdr_still_becomes_an_environment_map() {
2117 let dir = text_test_dir(line!());
2118 let path = dir.join("studio.hdr");
2119 std::fs::write(&path, b"#?RADIANCE\n").unwrap();
2120
2121 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
2122 assert_eq!(entries[0]["type"], "EnvironmentMap");
2123 }
2124
2125 #[test]
2126 fn an_fbx_never_takes_the_panorama_branch() {
2127 let entries = entry_from_path("scenes/bistro.fbx").unwrap();
2128 assert_eq!(entries[0]["type"], "SceneImport");
2129 }
2130
2131 #[test]
2134 fn ensure_world_file_exists_creates_parents_and_is_idempotent() {
2135 let dir = text_test_dir(line!());
2136 let world = dir.join("nested").join("deeper").join("world.jsonl");
2137
2138 ensure_world_file_exists(world.to_str().unwrap()).unwrap();
2139 assert!(world.exists());
2140 assert_eq!(std::fs::read_to_string(&world).unwrap(), "");
2141
2142 std::fs::write(&world, "content").unwrap();
2144 ensure_world_file_exists(world.to_str().unwrap()).unwrap();
2145 assert_eq!(std::fs::read_to_string(&world).unwrap(), "content");
2146
2147 let _ = std::fs::remove_dir_all(&dir);
2148 }
2149
2150 #[test]
2153 fn is_path_like_recognises_separators_prefixes_and_dotted_files() {
2154 assert!(is_path_like("models/scene.glb"));
2155 assert!(is_path_like("models\\scene.glb"));
2156 assert!(is_path_like("./scene.glb"));
2157 assert!(is_path_like("~/scene.glb"));
2158 assert!(is_path_like("scene.glb"));
2159 assert!(!is_path_like("Window"));
2161 }
2162}