1use crate::world::{WORLD_JSONL, patch_world_jsonl_to};
19use concinnity_cook::asset_api::{AssetRequest, create_asset_def};
20use concinnity_cook::authoring::registry::RegisteredType;
21use concinnity_cook::build_from_path;
22
23pub fn add_to_path(
30 world_path: &str,
31 name: Option<&str>,
32 target: &str,
33 template: Option<&str>,
34) -> std::io::Result<()> {
35 let scaffold = scaffold_to_inject(world_path, target, template)?;
36 ensure_world_file_exists(world_path)?;
37
38 let mut entries = resolve_add_target(target)?;
39
40 if let Some(n) = name {
41 apply_name_override(&mut entries, n);
42 }
43
44 let entry_names: Vec<String> = entries
45 .iter()
46 .map(|e| {
47 e.get("name")
48 .and_then(|v| v.as_str())
49 .ok_or_else(|| {
50 std::io::Error::new(
51 std::io::ErrorKind::InvalidData,
52 "resolved asset entry has no `name` field",
53 )
54 })
55 .map(str::to_string)
56 })
57 .collect::<Result<_, _>>()?;
58
59 let tmp_path = format!("{}.tmp", world_path);
60
61 patch_world_jsonl_to(world_path, &tmp_path, |assets| {
62 if let Some(refreshed) = try_refresh_text_label(assets, &entries) {
69 tracing::info!("refreshed TextLabel '{}' content", refreshed);
70 return Ok(());
71 }
72
73 if let Some((retargeted, source)) = try_retarget_environment_map(assets, &entries) {
77 println!("Pointed existing EnvironmentMap '{retargeted}' at {source}");
80 return Ok(());
81 }
82
83 for entry_name in &entry_names {
84 if let Some(existing) = assets
85 .iter()
86 .find(|a| a.get("name").and_then(|v| v.as_str()) == Some(entry_name.as_str()))
87 {
88 let existing_type = existing.get("type").and_then(|v| v.as_str()).unwrap_or("?");
89 return Err(std::io::Error::new(
90 std::io::ErrorKind::AlreadyExists,
91 format!(
92 "an asset named '{}' (type: {}) already exists in {}; \
93 remove it first with `concinnity rm {}`",
94 entry_name, existing_type, WORLD_JSONL, entry_name
95 ),
96 ));
97 }
98 }
99 for entry in scaffold {
104 let n = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
105 if !assets
106 .iter()
107 .any(|a| a.get("name").and_then(|v| v.as_str()) == Some(n))
108 {
109 assets.push(entry);
110 }
111 }
112 assets.extend(entries);
113 Ok(())
114 })?;
115
116 match build_from_path(
117 &crate::project::require()?,
118 &tmp_path,
119 crate::cook_platform(),
120 ) {
121 Ok(()) => std::fs::rename(&tmp_path, world_path).inspect_err(|_e| {
122 let _ = std::fs::remove_file(&tmp_path);
123 }),
124 Err(e) => {
125 let _ = std::fs::remove_file(&tmp_path);
126 Err(e)
127 }
128 }
129}
130
131pub(crate) fn apply_name_override(entries: &mut [serde_json::Value], name: &str) {
136 if let [only] = entries {
137 only["name"] = serde_json::Value::String(name.to_string());
138 return;
139 }
140 for entry in entries {
141 let existing = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
142 let suffix = existing
143 .rsplit_once('_')
144 .map(|(_, s)| format!("_{s}"))
145 .unwrap_or_default();
146 entry["name"] = serde_json::Value::String(format!("{name}{suffix}"));
147 }
148}
149
150fn try_refresh_text_label(
163 assets: &mut [serde_json::Value],
164 entries: &[serde_json::Value],
165) -> Option<String> {
166 if entries.len() != 1 {
167 return None;
168 }
169 let new = &entries[0];
170 if new.get("type").and_then(|v| v.as_str()) != Some("TextLabel") {
171 return None;
172 }
173 let new_name = new.get("name").and_then(|v| v.as_str())?.to_string();
174 let new_content = new.get("args").and_then(|a| a.get("content")).cloned()?;
175
176 let existing = assets.iter_mut().find(|a| {
177 a.get("name").and_then(|v| v.as_str()) == Some(new_name.as_str())
178 && a.get("type").and_then(|v| v.as_str()) == Some("TextLabel")
179 })?;
180 let args = existing.get_mut("args")?.as_object_mut()?;
181 args.insert("content".to_string(), new_content);
182 Some(new_name)
183}
184
185pub(crate) fn try_retarget_environment_map(
195 assets: &mut [serde_json::Value],
196 entries: &[serde_json::Value],
197) -> Option<(String, String)> {
198 if entries.len() != 1 {
199 return None;
200 }
201 let new = &entries[0];
202 if new.get("type").and_then(|v| v.as_str()) != Some("EnvironmentMap") {
203 return None;
204 }
205 let new_source = new.get("args")?.get("source")?.as_str()?.to_string();
206
207 let existing = assets
208 .iter_mut()
209 .find(|a| a.get("type").and_then(|v| v.as_str()) == Some("EnvironmentMap"))?;
210 let name = existing.get("name").and_then(|v| v.as_str())?.to_string();
211 let args = existing.get_mut("args")?.as_object_mut()?;
212 args.insert(
213 "source".to_string(),
214 serde_json::Value::String(new_source.clone()),
215 );
216 args.insert(
217 "generator".to_string(),
218 serde_json::Value::String(String::new()),
219 );
220 Some((name, new_source))
221}
222
223fn scaffold_to_inject(
238 world_path: &str,
239 target: &str,
240 template: Option<&str>,
241) -> std::io::Result<Vec<serde_json::Value>> {
242 let template_entries = resolve_template(template)?;
243
244 let world_exists = std::path::Path::new(world_path).exists();
245 let bootstrap = target_bootstrap_kind(target);
246
247 if !world_exists && matches!(bootstrap, BootstrapKind::None) {
248 return Err(std::io::Error::new(
249 std::io::ErrorKind::NotFound,
250 format!(
251 "no world found at '{}': create one with `cn init` or `cn new`",
252 world_path
253 ),
254 ));
255 }
256
257 if template_entries.is_some() && !matches!(bootstrap, BootstrapKind::Scene) {
258 return Err(std::io::Error::new(
259 std::io::ErrorKind::InvalidInput,
260 "--template only applies to 3D scene targets (.glb)",
261 ));
262 }
263
264 match bootstrap {
265 BootstrapKind::None | BootstrapKind::Text => Ok(Vec::new()),
266 BootstrapKind::Scene => {
267 if world_exists && has_renderer_trigger(world_path)? {
268 return Ok(Vec::new());
269 }
270 Ok(template_entries.unwrap_or_default())
274 }
275 }
276}
277
278fn resolve_template(template: Option<&str>) -> std::io::Result<Option<Vec<serde_json::Value>>> {
284 let Some(name) = template else {
285 return Ok(None);
286 };
287 match concinnity_cook::authoring::template::by_name(name) {
288 Some(t) => Ok(Some(
289 crate::authoring::template_spec::world_template_entries(t),
290 )),
291 None => Err(std::io::Error::new(
292 std::io::ErrorKind::InvalidInput,
293 format!(
294 "unknown template '{name}'; available: {}",
295 available_templates()
296 ),
297 )),
298 }
299}
300
301fn available_templates() -> String {
303 concinnity_cook::authoring::template::TEMPLATES
304 .iter()
305 .map(|t| t.name)
306 .collect::<Vec<_>>()
307 .join(", ")
308}
309
310fn has_renderer_trigger(world_path: &str) -> std::io::Result<bool> {
317 let content = std::fs::read_to_string(world_path)?;
318 Ok(jsonl_has_renderer_trigger(&content))
319}
320
321fn jsonl_has_renderer_trigger(content: &str) -> bool {
323 for line in content.lines() {
324 let line = line.trim();
325 if line.is_empty() {
326 continue;
327 }
328 let value: serde_json::Value = match serde_json::from_str(line) {
329 Ok(v) => v,
330 Err(_) => continue,
331 };
332 if let Some(t) = value.get("type").and_then(|v| v.as_str())
333 && concinnity_cook::authoring::registry::type_renders(t)
334 {
335 return true;
336 }
337 }
338 false
339}
340
341fn ensure_world_file_exists(world_path: &str) -> std::io::Result<()> {
345 if std::path::Path::new(world_path).exists() {
346 return Ok(());
347 }
348 if let Some(parent) = std::path::Path::new(world_path).parent()
349 && !parent.as_os_str().is_empty()
350 {
351 std::fs::create_dir_all(parent)?;
352 }
353 std::fs::write(world_path, "")?;
354 tracing::info!("created empty world file at {}", world_path);
355 Ok(())
356}
357
358pub(crate) enum BootstrapKind {
364 None,
365 Scene,
366 Text,
367}
368
369pub(crate) fn target_bootstrap_kind(target: &str) -> BootstrapKind {
370 let ext = std::path::Path::new(target)
371 .extension()
372 .and_then(|e| e.to_str())
373 .map(|e| e.to_ascii_lowercase());
374 match ext.as_deref() {
375 Some("glb") | Some("gltf") | Some("fbx") => BootstrapKind::Scene,
376 Some("txt") | Some("md") => BootstrapKind::Text,
377 _ => BootstrapKind::None,
378 }
379}
380
381pub(crate) fn is_path_like(s: &str) -> bool {
382 if s.contains('/') || s.contains('\\') {
383 return true;
384 }
385 if s.starts_with('.') || s.starts_with('~') {
386 return true;
387 }
388 if s.contains('.') {
390 return RegisteredType::parse(s).is_none();
391 }
392 false
393}
394
395fn validated_entry(
396 name: &str,
397 asset_type: &str,
398 args: serde_json::Value,
399) -> std::io::Result<serde_json::Value> {
400 if let Some(rt) = RegisteredType::parse(asset_type).filter(|t| t.is_resource()) {
403 let mut resolved = rt
404 .registration()
405 .default_args
406 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
407 if let (serde_json::Value::Object(base), serde_json::Value::Object(supplied)) =
408 (&mut resolved, &args)
409 {
410 for (k, v) in supplied {
411 base.insert(k.clone(), v.clone());
412 }
413 }
414 return Ok(serde_json::json!({
415 "name": name,
416 "type": asset_type,
417 "args": resolved,
418 }));
419 }
420
421 let req = AssetRequest {
422 asset_type: asset_type.to_string(),
423 args: Some(args.clone()),
424 };
425 create_asset_def(&req)
426 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
427 let resolved_args = normalized_args_value(asset_type, &args);
428
429 Ok(serde_json::json!({
430 "name": name,
431 "type": asset_type,
432 "args": resolved_args,
433 }))
434}
435
436fn normalized_args_value(asset_type: &str, args: &serde_json::Value) -> serde_json::Value {
441 let empty = || serde_json::Value::Object(Default::default());
442 let Some(ct) = RegisteredType::parse(asset_type) else {
443 return empty();
444 };
445 let mut merged = ct.registration().default_args.unwrap_or_else(empty);
446 if let (serde_json::Value::Object(base), serde_json::Value::Object(supplied)) =
447 (&mut merged, args)
448 {
449 for (k, v) in supplied {
450 base.insert(k.clone(), v.clone());
451 }
452 }
453 ct.normalized_args(&merged).unwrap_or_else(|_| empty())
454}
455
456fn import_entry(asset_type: &str, name: &str, source: &str) -> std::io::Result<serde_json::Value> {
462 let reg = RegisteredType::parse(asset_type)
463 .ok_or_else(|| std::io::Error::other(format!("{} asset type is unavailable", asset_type)))?
464 .registration();
465 let mut args = reg
466 .default_args
467 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
468 if let serde_json::Value::Object(map) = &mut args {
469 map.insert(
470 "source".to_string(),
471 serde_json::Value::String(source.to_string()),
472 );
473 }
474 Ok(serde_json::json!({
475 "name": name,
476 "type": asset_type,
477 "args": args,
478 }))
479}
480
481pub(crate) const IMPORT_EXTENSION_GROUPS: &[(&str, &[&str])] = &[
487 ("Scenes", &["glb", "gltf", "fbx"]),
488 ("Stories & text", &["md", "txt"]),
489 ("Images", &["png", "jpg", "jpeg", "bmp", "tga", "gif"]),
490 ("Textures", &["ktx2"]),
491 ("Environment maps", &["hdr"]),
492 ("Audio", &["ogg", "wav", "mp3", "flac"]),
493 ("Fonts", &["ttf", "otf"]),
494 ("Shaders", &["slang"]),
495 ("Models & data", &["obj", "mtl", "json", "gguf"]),
496];
497
498pub(crate) fn entry_from_path(path_str: &str) -> std::io::Result<Vec<serde_json::Value>> {
502 let path = std::path::Path::new(path_str);
503
504 let ext = path
505 .extension()
506 .and_then(|e| e.to_str())
507 .map(|e| e.to_lowercase())
508 .unwrap_or_default();
509
510 let stem = concinnity_cook::authoring::world::asset_name_from_path(path_str);
513
514 let base_name = if ext == "json" {
516 stem.clone()
517 } else {
518 path.file_name()
519 .and_then(|s| s.to_str())
520 .map(|s| s.replace('.', "_"))
521 .unwrap_or_else(|| path_str.to_string())
522 };
523
524 if ext == "json" {
525 return entry_from_json_file(path, &base_name).map(|e| vec![e]);
526 }
527
528 match ext.as_str() {
529 "slang" => {
532 let source = read_source_file(path_str)?;
533 slang_entry(&stem, path_str, &source)
534 }
535
536 "ttf" | "otf" => Ok(vec![validated_entry(
538 &stem,
539 "Font",
540 serde_json::json!({ "path": path_str }),
541 )?]),
542
543 "gguf" => Ok(vec![validated_entry(
545 &base_name,
546 "LLM",
547 serde_json::json!({ "lib_path": "", "model_path": path_str }),
548 )?]),
549
550 "ogg" | "wav" | "mp3" | "flac" => Ok(vec![validated_entry(
555 &stem,
556 "AudioClip",
557 serde_json::json!({ "source": path_str }),
558 )?]),
559
560 "hdr" => Ok(vec![environment_map_entry(&stem, path_str)?]),
566
567 "ktx2" => Ok(vec![validated_entry(
571 &base_name,
572 "Texture",
573 serde_json::json!({ "source": path_str }),
574 )?]),
575
576 "obj" | "mtl" | "png" | "jpg" | "jpeg" | "bmp" | "tga" | "gif" => {
578 Ok(vec![validated_entry(
579 &base_name,
580 "File",
581 serde_json::json!({ "path": path_str, "kind": ext.as_str() }),
582 )?])
583 }
584
585 "txt" => Ok(vec![text_label_entry(&stem, path_str)?]),
594
595 "md" => {
599 if md_has_frontmatter(path_str)? {
600 Ok(vec![import_entry("StoryImport", &stem, path_str)?])
601 } else {
602 Ok(vec![text_label_entry(&stem, path_str)?])
603 }
604 }
605
606 "glb" | "gltf" => Ok(vec![scene_or_panorama_entry(&stem, path_str)?]),
614 "fbx" => Ok(vec![import_entry("SceneImport", &stem, path_str)?]),
615
616 other => Err(std::io::Error::new(
617 std::io::ErrorKind::InvalidInput,
618 format!(
619 "unknown extension '.{}'; pass a type name instead \
620 (e.g. `concinnity add Logger`) or edit {} directly",
621 other, WORLD_JSONL
622 ),
623 )),
624 }
625}
626
627fn scene_or_panorama_entry(stem: &str, path_str: &str) -> std::io::Result<serde_json::Value> {
631 if concinnity_cook::import::panorama::file_is_panorama_sphere(path_str) {
632 tracing::info!(
633 "'{}' is a panorama sphere: importing it as an EnvironmentMap (sky \
634 and image-based lighting) rather than scene geometry",
635 path_str
636 );
637 return environment_map_entry(stem, path_str);
638 }
639 import_entry("SceneImport", stem, path_str)
640}
641
642fn environment_map_entry(stem: &str, path_str: &str) -> std::io::Result<serde_json::Value> {
643 validated_entry(
644 stem,
645 "EnvironmentMap",
646 serde_json::json!({ "source": path_str }),
647 )
648}
649
650fn slang_entry(
654 stem: &str,
655 path_str: &str,
656 source: &str,
657) -> std::io::Result<Vec<serde_json::Value>> {
658 if defines_distance_field(source) {
659 return Ok(vec![validated_entry(
660 &format!("{stem}_volume"),
661 "SdfVolume",
662 serde_json::json!({ "fragment_shader": path_str }),
663 )?]);
664 }
665 Ok(vec![validated_entry(
666 &format!("{stem}_shader"),
667 "Shader",
668 serde_json::json!({ "fragment": path_str }),
669 )?])
670}
671
672pub(crate) fn defines_distance_field(source: &str) -> bool {
675 source.lines().any(|l| {
676 let t = l.trim_start();
677 t.starts_with("float map(") || t.starts_with("float map (")
678 })
679}
680
681fn read_source_file(path_str: &str) -> std::io::Result<String> {
682 std::fs::read_to_string(path_str)
683 .map_err(|e| std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e)))
684}
685
686const TEXT_LABEL_MAX_BYTES: usize = 64 * 1024;
690
691fn read_text_content(path_str: &str) -> std::io::Result<String> {
698 let metadata = std::fs::metadata(path_str).map_err(|e| {
699 std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e))
700 })?;
701 if metadata.len() as usize > TEXT_LABEL_MAX_BYTES {
702 return Err(std::io::Error::new(
703 std::io::ErrorKind::InvalidInput,
704 format!(
705 "'{}' is {} bytes; TextLabel content is capped at {} bytes: \
706 trim the file or add it as a `File` asset via inline JSON instead",
707 path_str,
708 metadata.len(),
709 TEXT_LABEL_MAX_BYTES
710 ),
711 ));
712 }
713 let mut content = std::fs::read_to_string(path_str).map_err(|e| {
714 std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e))
715 })?;
716 if content.ends_with('\n') {
717 content.pop();
718 if content.ends_with('\r') {
719 content.pop();
720 }
721 }
722 Ok(content)
723}
724
725fn text_label_entry(name: &str, path_str: &str) -> std::io::Result<serde_json::Value> {
726 validated_entry(
727 name,
728 "TextLabel",
729 serde_json::json!({
730 "content": read_text_content(path_str)?,
731 "centered": true,
732 }),
733 )
734}
735
736fn md_has_frontmatter(path_str: &str) -> std::io::Result<bool> {
740 use std::io::BufRead;
741 let file = std::fs::File::open(path_str).map_err(|e| {
742 std::io::Error::new(e.kind(), format!("could not read '{}': {}", path_str, e))
743 })?;
744 let mut first = String::new();
745 std::io::BufReader::new(file).read_line(&mut first)?;
746 Ok(first.trim_start_matches('\u{feff}').trim_end() == "---")
747}
748
749fn entry_from_json_file(
750 path: &std::path::Path,
751 stem_name: &str,
752) -> std::io::Result<serde_json::Value> {
753 let content = std::fs::read_to_string(path).map_err(|e| {
754 std::io::Error::new(
755 e.kind(),
756 format!("could not read '{}': {}", path.display(), e),
757 )
758 })?;
759 let json: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
760 std::io::Error::new(
761 std::io::ErrorKind::InvalidData,
762 format!("could not parse '{}': {}", path.display(), e),
763 )
764 })?;
765
766 let asset_type = json.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
767 std::io::Error::new(
768 std::io::ErrorKind::InvalidData,
769 format!("'{}' has no `type` field", path.display()),
770 )
771 })?;
772
773 if asset_type.to_lowercase().replace('_', "") == "buildconfig" {
774 return Err(std::io::Error::new(
775 std::io::ErrorKind::InvalidInput,
776 format!(
777 "BuildConfig cannot be added via `concinnity add`; edit {} directly",
778 WORLD_JSONL
779 ),
780 ));
781 }
782
783 let args = json
784 .get("args")
785 .cloned()
786 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
787
788 let name = json
789 .get("name")
790 .and_then(|v| v.as_str())
791 .unwrap_or(stem_name)
792 .to_string();
793
794 validated_entry(&name, asset_type, args)
795}
796
797fn entry_from_inline_json(raw: &str) -> std::io::Result<serde_json::Value> {
798 let json: serde_json::Value = serde_json::from_str(raw).map_err(|e| {
799 std::io::Error::new(
800 std::io::ErrorKind::InvalidData,
801 format!("could not parse inline JSON: {}", e),
802 )
803 })?;
804
805 if !json.is_object() {
806 return Err(std::io::Error::new(
807 std::io::ErrorKind::InvalidData,
808 "inline JSON must be an object (e.g. '{\"type\": \"Window\"}')",
809 ));
810 }
811
812 let asset_type = json.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
813 std::io::Error::new(
814 std::io::ErrorKind::InvalidData,
815 "inline JSON must contain a `type` field",
816 )
817 })?;
818
819 if asset_type.to_lowercase().replace('_', "") == "buildconfig" {
820 return Err(std::io::Error::new(
821 std::io::ErrorKind::InvalidInput,
822 format!(
823 "BuildConfig cannot be added via `concinnity add`; edit {} directly",
824 WORLD_JSONL
825 ),
826 ));
827 }
828
829 let name = json
830 .get("name")
831 .and_then(|v| v.as_str())
832 .map(str::to_string)
833 .unwrap_or_else(|| asset_type.to_lowercase());
834
835 let args = json
836 .get("args")
837 .cloned()
838 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
839
840 let req = AssetRequest {
841 asset_type: asset_type.to_string(),
842 args: Some(args.clone()),
843 };
844 create_asset_def(&req)
845 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
846 let resolved_args = normalized_args_value(asset_type, &args);
847
848 Ok(serde_json::json!({
849 "name": name,
850 "type": asset_type,
851 "args": resolved_args,
852 }))
853}
854
855pub(crate) fn resolve_add_target(target: &str) -> std::io::Result<Vec<serde_json::Value>> {
859 if is_path_like(target) {
860 return entry_from_path(target);
861 }
862
863 if RegisteredType::parse(target).is_some() {
864 return entry_from_type_name(target).map(|e| vec![e]);
865 }
866
867 let as_path = std::path::Path::new(target);
868 if as_path.exists() && as_path.is_file() {
869 let name = as_path
870 .file_name()
871 .and_then(|s| s.to_str())
872 .map(|s| s.replace('.', "_"))
873 .unwrap_or_else(|| target.to_string());
874 return entry_from_json_file(as_path, &name).map(|e| vec![e]);
875 }
876
877 if target.trim_start().starts_with('{') {
878 return entry_from_inline_json(target).map(|e| vec![e]);
879 }
880
881 Err(std::io::Error::new(
882 std::io::ErrorKind::InvalidInput,
883 format!(
884 "could not resolve '{}' as any of:\n \
885 - a file path (no such file found)\n \
886 - a known asset type (use `concinnity list` to see available types)\n \
887 - an inline JSON object (must start with '{{' and contain a `type` field)",
888 target
889 ),
890 ))
891}
892
893fn entry_from_type_name(type_str: &str) -> std::io::Result<serde_json::Value> {
894 if type_str.to_lowercase().replace('_', "") == "buildconfig" {
895 return Err(std::io::Error::new(
896 std::io::ErrorKind::InvalidInput,
897 format!(
898 "BuildConfig cannot be added via `concinnity add`; edit {} directly",
899 WORLD_JSONL
900 ),
901 ));
902 }
903
904 let req = AssetRequest {
905 asset_type: type_str.to_string(),
906 args: None,
907 };
908 create_asset_def(&req)
909 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
910 let args = normalized_args_value(type_str, &serde_json::Value::Object(Default::default()));
911
912 let name = type_str.to_lowercase();
913
914 Ok(serde_json::json!({
915 "name": name,
916 "type": type_str,
917 "args": args,
918 }))
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924
925 #[test]
928 fn a_surface_file_becomes_a_fragment_only_shader() {
929 let entries = slang_entry(
930 "water",
931 "shaders/water.slang",
932 "float4 shade(VertexOut in, GpuObjectData od) { return 1.0; }",
933 )
934 .unwrap();
935 assert_eq!(entries.len(), 1);
936 assert_eq!(entries[0]["name"], "water_shader");
937 assert_eq!(entries[0]["type"], "Shader");
938 assert_eq!(entries[0]["args"]["fragment"], "shaders/water.slang");
939 assert!(entries[0]["args"].get("vertex").is_none_or(|v| v.is_null()));
940 }
941
942 #[test]
943 fn a_distance_field_becomes_an_sdf_volume() {
944 let src = "float map(float3 p, SdfParams q, float t) { return 1.0; }";
945 assert!(defines_distance_field(src));
946 assert!(defines_distance_field(
947 " float map (float3 p, SdfParams q, float t)"
948 ));
949 assert!(!defines_distance_field(
950 "float4 shade(VertexOut in, GpuObjectData od)"
951 ));
952 let entries = slang_entry("blob", "shaders/blob.slang", src).unwrap();
953 assert_eq!(entries[0]["name"], "blob_volume");
954 assert_eq!(entries[0]["type"], "SdfVolume");
955 assert_eq!(entries[0]["args"]["fragment_shader"], "shaders/blob.slang");
956 }
957
958 #[test]
961 fn font_name_uses_stem_only() {
962 let path = std::path::Path::new("fonts/JetBrainsMono-Regular.ttf");
963 let stem = path
964 .file_stem()
965 .and_then(|s| s.to_str())
966 .map(|s| s.replace('.', "_"))
967 .unwrap_or_default();
968 assert_eq!(stem, "JetBrainsMono-Regular");
970 assert!(!stem.contains("ttf"));
971 }
972
973 #[test]
976 fn unknown_extension_errors() {
977 let result = entry_from_path("assets/shader.xyz");
979 assert!(result.is_err());
980 let msg = result.unwrap_err().to_string();
981 assert!(msg.contains(".xyz"));
982 }
983
984 #[test]
987 fn target_bootstrap_kind_glb_is_scene() {
988 assert!(matches!(
989 target_bootstrap_kind("models/scene.glb"),
990 BootstrapKind::Scene
991 ));
992 assert!(matches!(
993 target_bootstrap_kind("scene.GLB"),
994 BootstrapKind::Scene
995 ));
996 }
997
998 #[test]
999 fn target_bootstrap_kind_text_is_text() {
1000 assert!(matches!(
1001 target_bootstrap_kind("notes.txt"),
1002 BootstrapKind::Text
1003 ));
1004 assert!(matches!(
1005 target_bootstrap_kind("README.MD"),
1006 BootstrapKind::Text
1007 ));
1008 }
1009
1010 #[test]
1011 fn target_bootstrap_kind_other_is_none() {
1012 assert!(matches!(
1013 target_bootstrap_kind("Logger"),
1014 BootstrapKind::None
1015 ));
1016 assert!(matches!(
1017 target_bootstrap_kind("shader.vert"),
1018 BootstrapKind::None
1019 ));
1020 assert!(matches!(
1021 target_bootstrap_kind("font.ttf"),
1022 BootstrapKind::None
1023 ));
1024 }
1025
1026 #[test]
1029 fn renderer_trigger_matches_graphics_config() {
1030 let jsonl = r#"{"name":"gc","type":"GraphicsConfig","args":{}}"#;
1031 assert!(jsonl_has_renderer_trigger(jsonl));
1032 }
1033
1034 #[test]
1035 fn renderer_trigger_matches_text_label() {
1036 let jsonl = r#"{"name":"lbl","type":"TextLabel","args":{}}"#;
1037 assert!(jsonl_has_renderer_trigger(jsonl));
1038 }
1039
1040 #[test]
1043 fn renderer_trigger_matches_prop() {
1044 let jsonl = r#"{"name":"crate","type":"Prop","args":{}}"#;
1045 assert!(jsonl_has_renderer_trigger(jsonl));
1046 }
1047
1048 #[test]
1051 fn renderer_trigger_ignores_window() {
1052 let jsonl = r#"{"name":"win","type":"Window","args":{}}"#;
1053 assert!(!jsonl_has_renderer_trigger(jsonl));
1054 }
1055
1056 #[test]
1057 fn renderer_trigger_absent_for_render_data_only_world() {
1058 let jsonl = concat!(
1061 r#"{"name":"tex","type":"Texture","args":{}}"#,
1062 "\n",
1063 r#"{"name":"mat","type":"Material","args":{}}"#,
1064 "\n",
1065 r#"{"name":"mesh","type":"Mesh","args":{}}"#,
1066 "\n",
1067 r#"{"name":"model","type":"Model","args":{}}"#,
1068 "\n",
1069 r#"{"name":"cam","type":"Camera3D","args":{}}"#,
1070 "\n",
1071 );
1072 assert!(!jsonl_has_renderer_trigger(jsonl));
1073 }
1074
1075 #[test]
1076 fn renderer_trigger_skips_malformed_lines() {
1077 let jsonl = concat!(
1078 "garbage line\n",
1079 r#"{"name":"tex","type":"Texture","args":{}}"#,
1080 "\n",
1081 );
1082 assert!(!jsonl_has_renderer_trigger(jsonl));
1083 }
1084
1085 #[test]
1086 fn renderer_trigger_handles_empty_jsonl() {
1087 assert!(!jsonl_has_renderer_trigger(""));
1088 }
1089
1090 #[test]
1093 fn scaffold_to_inject_writes_nothing_without_a_template() {
1094 let dir = concinnity_testing::TempTree::new();
1097 let world = dir.join("world.jsonl");
1098 std::fs::write(
1099 &world,
1100 concat!(
1101 r#"{"name":"tex","type":"Texture","args":{}}"#,
1102 "\n",
1103 r#"{"name":"cam","type":"Camera3D","args":{}}"#,
1104 "\n",
1105 ),
1106 )
1107 .unwrap();
1108
1109 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", None).unwrap();
1110 assert!(scaffold.is_empty());
1111 }
1112
1113 #[test]
1114 fn scaffold_to_inject_returns_empty_when_world_has_graphics_config() {
1115 let dir = concinnity_testing::TempTree::new();
1116 let world = dir.join("world.jsonl");
1117 std::fs::write(
1118 &world,
1119 r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
1120 )
1121 .unwrap();
1122
1123 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", None).unwrap();
1124 assert!(scaffold.is_empty());
1125 }
1126
1127 #[test]
1128 fn scaffold_to_inject_allows_missing_world_with_glb() {
1129 let dir = concinnity_testing::TempTree::new();
1133 let world = dir.join("world.jsonl");
1134
1135 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", None).unwrap();
1136 assert!(scaffold.is_empty());
1137 }
1138
1139 #[test]
1140 fn scaffold_to_inject_errors_for_missing_world_with_non_scene_target() {
1141 let dir = concinnity_testing::TempTree::new();
1142 let world = dir.join("world.jsonl");
1143
1144 let err = scaffold_to_inject(world.to_str().unwrap(), "Logger", None)
1145 .expect_err("missing world + non-scene target must error");
1146 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1147 }
1148
1149 #[test]
1150 fn scaffold_to_inject_returns_empty_for_non_scene_target_into_existing_world() {
1151 let dir = concinnity_testing::TempTree::new();
1152 let world = dir.join("world.jsonl");
1153 std::fs::write(&world, "").unwrap();
1154
1155 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "Logger", None).unwrap();
1158 assert!(scaffold.is_empty());
1159 }
1160
1161 #[test]
1164 fn scaffold_to_inject_uses_named_template() {
1165 let dir = concinnity_testing::TempTree::new();
1166 let world = dir.join("world.jsonl");
1167
1168 let name = concinnity_cook::authoring::template::TEMPLATES[0].name;
1169 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", Some(name))
1170 .expect("a named template should apply for a renderer-less glb add");
1171 let expected = concinnity_cook::authoring::template::by_name(name)
1172 .unwrap()
1173 .assets()
1174 .len();
1175 assert_eq!(scaffold.len(), expected, "expected the template's entries");
1176 assert!(!scaffold.is_empty());
1177 }
1178
1179 #[test]
1185 fn every_template_entry_validates_as_a_real_asset() {
1186 let _guard = crate::test_support::lock();
1187 for t in concinnity_cook::authoring::template::TEMPLATES {
1188 let entries = crate::authoring::template_spec::world_template_entries(t);
1189 assert!(!entries.is_empty(), "template '{}' is empty", t.name);
1190 for entry in entries {
1191 let ty = entry["type"].as_str().expect("entry has a type");
1192 let name = entry["name"].as_str().expect("entry has a name");
1193 let args = entry
1194 .get("args")
1195 .cloned()
1196 .unwrap_or_else(|| serde_json::json!({}));
1197 concinnity_cook::validate_asset(ty, name, &args).unwrap_or_else(|e| {
1198 panic!(
1199 "template '{}' entry '{name}' ({ty}) failed to validate: {e}",
1200 t.name
1201 )
1202 });
1203 }
1204 }
1205 }
1206
1207 #[test]
1208 fn scaffold_to_inject_errors_on_unknown_template() {
1209 let dir = concinnity_testing::TempTree::new();
1210 let world = dir.join("world.jsonl");
1211
1212 let err = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", Some("nope"))
1213 .expect_err("unknown template name must surface as InvalidInput");
1214 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1215 assert!(
1216 err.to_string().contains("nope"),
1217 "error should name the bad template: {err}"
1218 );
1219 }
1220
1221 #[test]
1222 fn scaffold_to_inject_rejects_unknown_template_even_when_no_scaffold_would_fire() {
1223 let dir = concinnity_testing::TempTree::new();
1227 let world = dir.join("world.jsonl");
1228 std::fs::write(
1230 &world,
1231 r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
1232 )
1233 .unwrap();
1234
1235 let err = scaffold_to_inject(world.to_str().unwrap(), "scene.glb", Some("nope"))
1236 .expect_err("unknown template should fail fast regardless of scaffold path");
1237 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1238 }
1239
1240 #[test]
1243 fn text_file_becomes_text_label_with_content() {
1244 let dir = concinnity_testing::TempTree::new();
1245 let path = dir.join("greeting.txt");
1246 std::fs::write(&path, "Hello, world!").unwrap();
1247
1248 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1249 assert_eq!(entries.len(), 1);
1250 let entry = &entries[0];
1251 assert_eq!(entry["type"], "TextLabel");
1252 assert_eq!(entry["name"], "greeting");
1253 assert_eq!(entry["args"]["content"], "Hello, world!");
1254 assert_eq!(entry["args"]["centered"], serde_json::json!(true));
1256 }
1257
1258 #[test]
1259 fn text_file_strips_single_trailing_newline() {
1260 let dir = concinnity_testing::TempTree::new();
1261 let lf = dir.join("lf.txt");
1262 std::fs::write(&lf, "line one\nline two\n").unwrap();
1263 let crlf = dir.join("crlf.md");
1264 std::fs::write(&crlf, "line one\r\nline two\r\n").unwrap();
1265
1266 let lf_entry = &entry_from_path(lf.to_str().unwrap()).unwrap()[0];
1267 assert_eq!(lf_entry["args"]["content"], "line one\nline two");
1268 let crlf_entry = &entry_from_path(crlf.to_str().unwrap()).unwrap()[0];
1269 assert_eq!(crlf_entry["args"]["content"], "line one\r\nline two");
1270 }
1271
1272 #[test]
1273 fn audio_file_becomes_audio_clip() {
1274 let dir = concinnity_testing::TempTree::new();
1275 let path = dir.join("door_creak.wav");
1276 std::fs::write(&path, b"not-really-audio").unwrap();
1277
1278 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1279 assert_eq!(entries.len(), 1);
1280 let entry = &entries[0];
1281 assert_eq!(entry["type"], "AudioClip");
1282 assert_eq!(entry["name"], "door_creak");
1283 assert_eq!(entry["args"]["source"], path.to_str().unwrap());
1284 }
1285
1286 #[test]
1287 fn hdr_file_becomes_an_environment_map() {
1288 let dir = concinnity_testing::TempTree::new();
1289 let path = dir.join("san_giuseppe_4k.hdr");
1290 std::fs::write(&path, b"not-really-radiance").unwrap();
1291
1292 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1293 assert_eq!(entries.len(), 1);
1294 let entry = &entries[0];
1295 assert_eq!(entry["type"], "EnvironmentMap");
1296 assert_eq!(entry["name"], "san_giuseppe_4k");
1297 assert_eq!(entry["args"]["source"], path.to_str().unwrap());
1298 assert_eq!(entry["args"]["generator"], "");
1301 assert_eq!(entry["args"]["prefilter_face_size"], serde_json::json!(512));
1303 }
1304
1305 #[test]
1307 fn an_hdr_entry_validates_against_the_environment_map_schema() {
1308 let _guard = crate::test_support::lock();
1309 let dir = concinnity_testing::TempTree::new();
1310 let path = dir.join("studio.hdr");
1311 std::fs::write(&path, b"radiance").unwrap();
1312
1313 let entry = entry_from_path(path.to_str().unwrap()).unwrap().remove(0);
1314 concinnity_cook::validate_asset("EnvironmentMap", "studio", &entry["args"])
1315 .expect("a `.hdr` add must produce a cookable EnvironmentMap");
1316 }
1317
1318 #[test]
1324 fn try_retarget_environment_map_repoints_the_existing_map() {
1325 let mut assets = vec![serde_json::json!({
1326 "name": "env_sky",
1327 "type": "EnvironmentMap",
1328 "args": {
1329 "source": "assets/hdri/old.hdr",
1330 "generator": "",
1331 "prefilter_face_size": 1024,
1332 "prefilter_clamp": 4.0,
1333 }
1334 })];
1335 let entries = vec![serde_json::json!({
1336 "name": "studio",
1337 "type": "EnvironmentMap",
1338 "args": {"source": "assets/hdri/studio.hdr", "generator": ""}
1339 })];
1340
1341 let out = try_retarget_environment_map(&mut assets, &entries);
1342 assert_eq!(
1343 out,
1344 Some(("env_sky".to_string(), "assets/hdri/studio.hdr".to_string()))
1345 );
1346 assert_eq!(assets.len(), 1, "no second map appended");
1347 let args = assets[0]["args"].as_object().unwrap();
1348 assert_eq!(args["source"], "assets/hdri/studio.hdr");
1349 assert_eq!(args["prefilter_face_size"], 1024);
1351 assert_eq!(args["prefilter_clamp"], 4.0);
1352 assert_eq!(assets[0]["name"], "env_sky");
1354 }
1355
1356 #[test]
1359 fn try_retarget_environment_map_clears_a_generator() {
1360 let mut assets = vec![serde_json::json!({
1361 "name": "env",
1362 "type": "EnvironmentMap",
1363 "args": {"source": "", "generator": "sky"}
1364 })];
1365 let entries = vec![serde_json::json!({
1366 "name": "dusk",
1367 "type": "EnvironmentMap",
1368 "args": {"source": "dusk.hdr", "generator": ""}
1369 })];
1370
1371 assert!(try_retarget_environment_map(&mut assets, &entries).is_some());
1372 let args = assets[0]["args"].as_object().unwrap();
1373 assert_eq!(args["source"], "dusk.hdr");
1374 assert_eq!(args["generator"], "");
1375 }
1376
1377 #[test]
1379 fn try_retarget_environment_map_takes_the_first_of_several() {
1380 let mut assets = vec![
1381 serde_json::json!({"name": "a", "type": "EnvironmentMap", "args": {"source": "a.hdr"}}),
1382 serde_json::json!({"name": "b", "type": "EnvironmentMap", "args": {"source": "b.hdr"}}),
1383 ];
1384 let entries = vec![
1385 serde_json::json!({"name": "c", "type": "EnvironmentMap", "args": {"source": "c.hdr"}}),
1386 ];
1387
1388 let (name, _) = try_retarget_environment_map(&mut assets, &entries).unwrap();
1389 assert_eq!(name, "a");
1390 assert_eq!(assets[0]["args"]["source"], "c.hdr");
1391 assert_eq!(
1392 assets[1]["args"]["source"], "b.hdr",
1393 "the rest are untouched"
1394 );
1395 }
1396
1397 #[test]
1399 fn try_retarget_environment_map_skips_a_world_without_one() {
1400 let mut assets = vec![serde_json::json!({
1401 "name": "lamp", "type": "PointLight", "args": {}
1402 })];
1403 let entries = vec![
1404 serde_json::json!({"name": "env", "type": "EnvironmentMap", "args": {"source": "e.hdr"}}),
1405 ];
1406 assert!(try_retarget_environment_map(&mut assets, &entries).is_none());
1407 }
1408
1409 #[test]
1410 fn try_retarget_environment_map_skips_other_types() {
1411 let mut assets = vec![serde_json::json!({
1412 "name": "env", "type": "EnvironmentMap", "args": {"source": "e.hdr"}
1413 })];
1414 let entries =
1415 vec![serde_json::json!({"name": "face", "type": "Font", "args": {"path": "face.ttf"}})];
1416 assert!(try_retarget_environment_map(&mut assets, &entries).is_none());
1417 assert_eq!(assets[0]["args"]["source"], "e.hdr");
1418 }
1419
1420 #[test]
1422 fn try_retarget_environment_map_skips_multi_entry() {
1423 let mut assets = vec![serde_json::json!({
1424 "name": "env", "type": "EnvironmentMap", "args": {"source": "e.hdr"}
1425 })];
1426 let entries = vec![
1427 serde_json::json!({"name": "a", "type": "EnvironmentMap", "args": {"source": "a.hdr"}}),
1428 serde_json::json!({"name": "b", "type": "EnvironmentMap", "args": {"source": "b.hdr"}}),
1429 ];
1430 assert!(try_retarget_environment_map(&mut assets, &entries).is_none());
1431 }
1432
1433 #[test]
1434 fn markdown_with_frontmatter_becomes_story_import() {
1435 let dir = concinnity_testing::TempTree::new();
1436 let path = dir.join("crossroads.md");
1437 std::fs::write(&path, "---\ntitle: T\n---\n\n# a\n\nhi\n").unwrap();
1438
1439 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1440 assert_eq!(entries.len(), 1);
1441 let entry = &entries[0];
1442 assert_eq!(entry["type"], "StoryImport");
1443 assert_eq!(entry["name"], "crossroads");
1444 assert_eq!(entry["args"]["source"], path.to_str().unwrap());
1445 assert_eq!(entry["args"]["title_screen"], serde_json::json!(true));
1447 }
1448
1449 #[test]
1450 fn markdown_without_frontmatter_stays_a_text_label() {
1451 let dir = concinnity_testing::TempTree::new();
1452 let path = dir.join("notes.md");
1453 std::fs::write(&path, "# Notes\n\nplain markdown\n").unwrap();
1454
1455 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1456 assert_eq!(entries.len(), 1);
1457 assert_eq!(entries[0]["type"], "TextLabel");
1458 }
1459
1460 #[test]
1461 fn text_file_over_size_cap_errors() {
1462 let dir = concinnity_testing::TempTree::new();
1463 let path = dir.join("huge.txt");
1464 let blob = "a".repeat(TEXT_LABEL_MAX_BYTES + 1);
1465 std::fs::write(&path, blob).unwrap();
1466
1467 let err = entry_from_path(path.to_str().unwrap())
1468 .expect_err("oversized text file must fail rather than silently truncate");
1469 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1470 assert!(err.to_string().contains("capped"));
1471 }
1472
1473 #[test]
1474 fn scaffold_to_inject_empty_for_missing_world_with_text_target() {
1475 let dir = concinnity_testing::TempTree::new();
1478 let world = dir.join("world.jsonl");
1479
1480 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "notes.txt", None).unwrap();
1481 assert!(scaffold.is_empty(), "text target should emit no scaffold");
1482 }
1483
1484 #[test]
1485 fn scaffold_to_inject_empty_for_renderer_less_world_with_text_target() {
1486 let dir = concinnity_testing::TempTree::new();
1487 let world = dir.join("world.jsonl");
1488 std::fs::write(&world, r#"{"name":"tex","type":"Texture","args":{}}"#).unwrap();
1489
1490 let scaffold = scaffold_to_inject(world.to_str().unwrap(), "notes.md", None).unwrap();
1491 assert!(
1492 scaffold.is_empty(),
1493 "text target never injects GLB scaffold"
1494 );
1495 }
1496
1497 #[test]
1500 fn try_refresh_text_label_updates_content_in_place() {
1501 let mut assets = vec![serde_json::json!({
1504 "name": "greeting",
1505 "type": "TextLabel",
1506 "args": {
1507 "content": "Old text",
1508 "font": "Questrial-Regular",
1509 "x": 10.0,
1510 "y": 200.0,
1511 "color": [0.2, 0.8, 0.4],
1512 "scale": 1.5,
1513 "centered": false,
1514 "background": [0.0, 0.0, 0.0, 0.0],
1515 "padding": 0.0,
1516 "visible": true,
1517 "screen": null,
1518 }
1519 })];
1520 let entries = vec![serde_json::json!({
1521 "name": "greeting",
1522 "type": "TextLabel",
1523 "args": {
1524 "content": "New text",
1525 "centered": true,
1526 }
1527 })];
1528
1529 let refreshed = try_refresh_text_label(&mut assets, &entries);
1530 assert_eq!(refreshed.as_deref(), Some("greeting"));
1531 let args = assets[0]["args"].as_object().unwrap();
1532 assert_eq!(args["content"], "New text");
1533 assert_eq!(args["y"], 200.0);
1535 assert_eq!(args["color"], serde_json::json!([0.2, 0.8, 0.4]));
1536 assert_eq!(args["scale"], 1.5);
1537 assert_eq!(args["centered"], false);
1538 }
1539
1540 #[test]
1541 fn try_refresh_text_label_skips_non_textlabel_new_entry() {
1542 let mut assets = vec![serde_json::json!({
1543 "name": "thing",
1544 "type": "TextLabel",
1545 "args": {"content": "old"}
1546 })];
1547 let entries = vec![serde_json::json!({
1548 "name": "thing",
1549 "type": "Font",
1550 "args": {"path": "x.ttf"}
1551 })];
1552 assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1553 }
1554
1555 #[test]
1556 fn try_refresh_text_label_skips_when_existing_is_different_type() {
1557 let mut assets = vec![serde_json::json!({
1558 "name": "thing",
1559 "type": "Font",
1560 "args": {"path": "x.ttf"}
1561 })];
1562 let entries = vec![serde_json::json!({
1563 "name": "thing",
1564 "type": "TextLabel",
1565 "args": {"content": "hi"}
1566 })];
1567 assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1570 }
1571
1572 #[test]
1573 fn try_refresh_text_label_skips_when_name_misses() {
1574 let mut assets = vec![serde_json::json!({
1575 "name": "greeting",
1576 "type": "TextLabel",
1577 "args": {"content": "old"}
1578 })];
1579 let entries = vec![serde_json::json!({
1580 "name": "caption",
1581 "type": "TextLabel",
1582 "args": {"content": "new"}
1583 })];
1584 assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1585 }
1586
1587 #[test]
1588 fn try_refresh_text_label_skips_multi_entry() {
1589 let mut assets = vec![serde_json::json!({
1592 "name": "label",
1593 "type": "TextLabel",
1594 "args": {"content": "old"}
1595 })];
1596 let entries = vec![
1597 serde_json::json!({"name": "label", "type": "TextLabel", "args": {"content": "new"}}),
1598 serde_json::json!({"name": "other", "type": "TextLabel", "args": {"content": "other"}}),
1599 ];
1600 assert!(try_refresh_text_label(&mut assets, &entries).is_none());
1601 }
1602
1603 #[test]
1604 fn scaffold_to_inject_rejects_template_with_text_target() {
1605 let dir = concinnity_testing::TempTree::new();
1606 let world = dir.join("world.jsonl");
1607
1608 let name = concinnity_cook::authoring::template::TEMPLATES[0].name;
1609 let err = scaffold_to_inject(world.to_str().unwrap(), "notes.txt", Some(name))
1610 .expect_err("--template should only apply to scene targets");
1611 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1612 }
1613
1614 #[test]
1617 fn inline_json_must_be_an_object() {
1618 let err = entry_from_inline_json("[1, 2]").unwrap_err();
1619 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1620 assert!(err.to_string().contains("must be an object"), "got: {err}");
1621 }
1622
1623 #[test]
1624 fn inline_json_requires_a_type_field() {
1625 let err = entry_from_inline_json(r#"{"name":"thing"}"#).unwrap_err();
1626 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1627 assert!(err.to_string().contains("`type` field"), "got: {err}");
1628 }
1629
1630 #[test]
1631 fn inline_json_rejects_malformed_text() {
1632 let err = entry_from_inline_json("{ nope").unwrap_err();
1633 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1634 }
1635
1636 #[test]
1637 fn inline_json_rejects_build_config() {
1638 let err = entry_from_inline_json(r#"{"type":"BuildConfig"}"#).unwrap_err();
1639 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1640 let err = entry_from_inline_json(r#"{"type":"build_config"}"#).unwrap_err();
1642 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1643 }
1644
1645 #[test]
1646 fn inline_json_defaults_the_name_to_the_lowercased_type() {
1647 let entry = entry_from_inline_json(r#"{"type":"Window"}"#).unwrap();
1648 assert_eq!(entry["name"], "window");
1649 assert_eq!(entry["type"], "Window");
1650 assert!(entry["args"].is_object());
1652 }
1653
1654 #[test]
1655 fn inline_json_keeps_an_explicit_name() {
1656 let entry = entry_from_inline_json(r#"{"type":"Window","name":"main"}"#).unwrap();
1657 assert_eq!(entry["name"], "main");
1658 }
1659
1660 #[test]
1663 fn type_name_builds_a_default_entry() {
1664 let entry = entry_from_type_name("Window").unwrap();
1665 assert_eq!(entry["name"], "window");
1666 assert_eq!(entry["type"], "Window");
1667 assert!(entry["args"].is_object());
1668 }
1669
1670 #[test]
1671 fn type_name_rejects_build_config() {
1672 let err = entry_from_type_name("BuildConfig").unwrap_err();
1673 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1674 }
1675
1676 #[test]
1677 fn type_name_rejects_an_unknown_type() {
1678 assert!(entry_from_type_name("NotARealAssetType").is_err());
1679 }
1680
1681 #[test]
1684 fn json_file_requires_a_type_field() {
1685 let dir = concinnity_testing::TempTree::new();
1686 let path = dir.join("thing.json");
1687 std::fs::write(&path, r#"{"name":"thing"}"#).unwrap();
1688
1689 let err = entry_from_json_file(&path, "thing").unwrap_err();
1690 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1691 assert!(err.to_string().contains("no `type` field"), "got: {err}");
1692 }
1693
1694 #[test]
1695 fn json_file_rejects_build_config() {
1696 let dir = concinnity_testing::TempTree::new();
1697 let path = dir.join("cfg.json");
1698 std::fs::write(&path, r#"{"type":"BuildConfig"}"#).unwrap();
1699
1700 let err = entry_from_json_file(&path, "cfg").unwrap_err();
1701 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1702 }
1703
1704 #[test]
1705 fn json_file_uses_the_stem_when_unnamed() {
1706 let dir = concinnity_testing::TempTree::new();
1707 let path = dir.join("main_window.json");
1708 std::fs::write(&path, r#"{"type":"Window","args":{}}"#).unwrap();
1709
1710 let entry = entry_from_json_file(&path, "main_window").unwrap();
1711 assert_eq!(entry["name"], "main_window");
1712 assert_eq!(entry["type"], "Window");
1713 }
1714
1715 #[test]
1716 fn json_file_read_and_parse_failures_are_reported() {
1717 let dir = concinnity_testing::TempTree::new();
1718 let missing = dir.join("missing.json");
1720 assert!(entry_from_json_file(&missing, "missing").is_err());
1721 let junk = dir.join("junk.json");
1723 std::fs::write(&junk, "{ nope").unwrap();
1724 let err = entry_from_json_file(&junk, "junk").unwrap_err();
1725 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1726 }
1727
1728 #[test]
1731 fn resolve_add_target_dispatches_type_names_and_inline_json() {
1732 let entries = resolve_add_target("Window").unwrap();
1733 assert_eq!(entries.len(), 1);
1734 assert_eq!(entries[0]["type"], "Window");
1735
1736 let entries = resolve_add_target(r#"{"type":"Window","name":"main"}"#).unwrap();
1737 assert_eq!(entries.len(), 1);
1738 assert_eq!(entries[0]["name"], "main");
1739 }
1740
1741 #[test]
1742 fn resolve_add_target_reports_an_unresolvable_target() {
1743 let err = resolve_add_target("DefinitelyNotAnAssetOrFile").unwrap_err();
1744 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1745 assert!(err.to_string().contains("could not resolve"), "got: {err}");
1746 }
1747
1748 #[test]
1752 fn import_extension_groups_track_the_dispatch() {
1753 let dir = tempfile::tempdir().unwrap();
1754 for (_, exts) in IMPORT_EXTENSION_GROUPS {
1755 for ext in *exts {
1756 let path = dir.path().join(format!("probe.{ext}"));
1757 std::fs::write(&path, b"{}").unwrap();
1759 let path_str = path.to_string_lossy();
1760 if let Err(e) = entry_from_path(&path_str) {
1761 assert!(
1762 !e.to_string().contains("unknown extension"),
1763 ".{ext} is advertised by the picker but not dispatched: {e}"
1764 );
1765 }
1766 }
1767 }
1768 }
1769
1770 #[test]
1772 fn import_extension_groups_are_non_empty_and_unique() {
1773 let mut seen = std::collections::HashSet::new();
1774 for (name, exts) in IMPORT_EXTENSION_GROUPS {
1775 assert!(!exts.is_empty(), "{name} has no extensions");
1776 for ext in *exts {
1777 assert!(seen.insert(*ext), "'{ext}' listed in two groups");
1778 assert!(
1779 !ext.starts_with('.'),
1780 "'{ext}' should be bare (rfd adds the dot)"
1781 );
1782 }
1783 }
1784 }
1785
1786 #[test]
1789 fn import_entry_sets_the_source_on_registration_defaults() {
1790 let entry = import_entry("SceneImport", "bistro", "scenes/bistro.fbx").unwrap();
1791 assert_eq!(entry["name"], "bistro");
1792 assert_eq!(entry["type"], "SceneImport");
1793 assert_eq!(entry["args"]["source"], "scenes/bistro.fbx");
1794 }
1795
1796 #[test]
1797 fn import_entry_rejects_an_unknown_type() {
1798 assert!(import_entry("NotARealAssetType", "x", "x.glb").is_err());
1799 }
1800
1801 #[test]
1808 fn a_glb_that_is_not_a_panorama_imports_as_geometry() {
1809 let dir = concinnity_testing::TempTree::new();
1810 let path = dir.join("scene.glb");
1811 std::fs::write(&path, b"not a panorama").unwrap();
1812
1813 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1814 assert_eq!(entries.len(), 1);
1815 assert_eq!(entries[0]["type"], "SceneImport");
1816 assert_eq!(entries[0]["args"]["source"], path.to_str().unwrap());
1817 }
1818
1819 #[test]
1820 fn a_panorama_becomes_an_environment_map_naming_its_source() {
1821 let entry = environment_map_entry("galaxy", "assets/hdri/galaxy.glb").unwrap();
1822 assert_eq!(entry["name"], "galaxy");
1823 assert_eq!(entry["type"], "EnvironmentMap");
1824 assert_eq!(entry["args"]["source"], "assets/hdri/galaxy.glb");
1825 }
1826
1827 #[test]
1828 fn an_hdr_still_becomes_an_environment_map() {
1829 let dir = concinnity_testing::TempTree::new();
1830 let path = dir.join("studio.hdr");
1831 std::fs::write(&path, b"#?RADIANCE\n").unwrap();
1832
1833 let entries = entry_from_path(path.to_str().unwrap()).unwrap();
1834 assert_eq!(entries[0]["type"], "EnvironmentMap");
1835 }
1836
1837 #[test]
1838 fn an_fbx_never_takes_the_panorama_branch() {
1839 let entries = entry_from_path("scenes/bistro.fbx").unwrap();
1840 assert_eq!(entries[0]["type"], "SceneImport");
1841 }
1842
1843 #[test]
1846 fn ensure_world_file_exists_creates_parents_and_is_idempotent() {
1847 let dir = concinnity_testing::TempTree::new();
1848 let world = dir.join("nested").join("deeper").join("world.jsonl");
1849
1850 ensure_world_file_exists(world.to_str().unwrap()).unwrap();
1851 assert!(world.exists());
1852 assert_eq!(std::fs::read_to_string(&world).unwrap(), "");
1853
1854 std::fs::write(&world, "content").unwrap();
1856 ensure_world_file_exists(world.to_str().unwrap()).unwrap();
1857 assert_eq!(std::fs::read_to_string(&world).unwrap(), "content");
1858 }
1859
1860 #[test]
1863 fn is_path_like_recognises_separators_prefixes_and_dotted_files() {
1864 assert!(is_path_like("models/scene.glb"));
1865 assert!(is_path_like("models\\scene.glb"));
1866 assert!(is_path_like("./scene.glb"));
1867 assert!(is_path_like("~/scene.glb"));
1868 assert!(is_path_like("scene.glb"));
1869 assert!(!is_path_like("Window"));
1871 }
1872}