Skip to main content

concinnity_dev/command/
list.rs

1// src/cli/list.rs
2use concinnity_cook::authoring::registry::RegisteredType;
3use concinnity_cook::authoring::world::{find_world_jsonl, parse_world_jsonl, resolve_includes};
4
5// Authoring metadata for a type name, whichever group of the registry it is in.
6fn registration_for(type_str: &str) -> Option<concinnity_cook::authoring::registry::Registration> {
7    RegisteredType::parse(type_str).map(RegisteredType::registration)
8}
9
10// Resolve the world path the same way every other subcommand does: an explicit
11// existing path wins, otherwise discover from .concinnity/worlds/ or cwd.
12pub(crate) fn resolve_world_path(json_path: Option<&str>) -> std::io::Result<String> {
13    match json_path {
14        Some(p) if std::path::Path::new(p).exists() => Ok(p.to_string()),
15        _ => find_world_jsonl(crate::project::worlds_dir().as_deref(), None),
16    }
17}
18
19// Provenance of one expanded-world row: declared in the file, added by an
20// injection pass, or generated by a build-time macro expansion. The
21// classification lives in cook (`LoadedWorld::provenance`), shared with the
22// editor's Expanded tab; this is its printed form.
23pub(crate) fn provenance(loaded: &concinnity_cook::build_only::LoadedWorld, name: &str) -> String {
24    loaded.provenance(name).to_string()
25}
26
27/// Print every declared asset. `expanded` includes the assets the build
28/// injects; `systems` adds the system manifest the world resolves to.
29pub fn list(json_path: Option<&str>, expanded: bool, systems: bool) -> std::io::Result<()> {
30    let json_path = resolve_world_path(json_path)?;
31
32    let content = std::fs::read_to_string(&json_path).map_err(|e| {
33        tracing::error!("Could not read {}: {}", json_path, e);
34        e
35    })?;
36
37    if systems {
38        return list_systems(&content, &json_path);
39    }
40    if expanded {
41        return list_expanded(&content, &json_path);
42    }
43
44    let assets = parse_world_jsonl(&content).map_err(|e| {
45        std::io::Error::new(
46            std::io::ErrorKind::InvalidData,
47            format!("Failed to parse {}: {}", json_path, e),
48        )
49    })?;
50
51    let raw = resolve_includes(assets)?;
52
53    if raw.is_empty() {
54        println!("{} has no assets.", json_path);
55        return Ok(());
56    }
57
58    // collect rows, then print aligned
59    struct Row {
60        name: String,
61        type_str: String,
62        origin: String,
63        payload: String,
64    }
65
66    let rows: Vec<Row> = raw
67        .iter()
68        .map(|v| {
69            let name = v
70                .get("name")
71                .and_then(|n| n.as_str())
72                .unwrap_or("(unnamed)")
73                .to_string();
74            let type_str = v
75                .get("type")
76                .and_then(|t| t.as_str())
77                .unwrap_or("?")
78                .to_string();
79
80            let (origin, payload) = if let Some(r) = registration_for(&type_str) {
81                (format!("{:?}", r.origin), format!("{:?}", r.payload))
82            } else {
83                ("?".to_string(), "?".to_string())
84            };
85
86            Row {
87                name,
88                type_str,
89                origin,
90                payload,
91            }
92        })
93        .collect();
94
95    let w_name = rows.iter().map(|r| r.name.len()).max().unwrap_or(4).max(4);
96    let w_type = rows
97        .iter()
98        .map(|r| r.type_str.len())
99        .max()
100        .unwrap_or(4)
101        .max(4);
102    let w_origin = rows
103        .iter()
104        .map(|r| r.origin.len())
105        .max()
106        .unwrap_or(6)
107        .max(6);
108
109    println!(
110        "{:<w_name$}  {:<w_type$}  {:<w_origin$}  PAYLOAD",
111        "NAME",
112        "TYPE",
113        "ORIGIN",
114        w_name = w_name,
115        w_type = w_type,
116        w_origin = w_origin,
117    );
118    println!("{}", "-".repeat(w_name + w_type + w_origin + 16));
119
120    for r in &rows {
121        println!(
122            "{:<w_name$}  {:<w_type$}  {:<w_origin$}  {}",
123            r.name,
124            r.type_str,
125            r.origin,
126            r.payload,
127            w_name = w_name,
128            w_type = w_type,
129            w_origin = w_origin,
130        );
131    }
132
133    println!("\n{} asset(s) in {}", rows.len(), json_path);
134    Ok(())
135}
136
137// The expanded world: every asset the build produces, with its provenance.
138// Runs the same front half as `cn build` (expansion passes, injection,
139// semantic validation), so the listing is exactly what lands in the blob.
140fn list_expanded(content: &str, json_path: &str) -> std::io::Result<()> {
141    let loaded = concinnity_cook::prepare_world(
142        content,
143        crate::project::assets_dir().as_deref(),
144        crate::cook_platform(),
145    )
146    .map_err(|errs| concinnity_cook::check::report_validation_errors(&errs))?;
147
148    if loaded.assets.is_empty() {
149        println!("{} expands to no assets.", json_path);
150        return Ok(());
151    }
152
153    let rows: Vec<(String, String, String)> = loaded
154        .assets
155        .iter()
156        .map(|a| {
157            (
158                a.name.clone(),
159                a.asset_type.clone(),
160                provenance(&loaded, &a.name),
161            )
162        })
163        .collect();
164
165    let w_name = rows.iter().map(|r| r.0.len()).max().unwrap_or(4).max(4);
166    let w_type = rows.iter().map(|r| r.1.len()).max().unwrap_or(4).max(4);
167
168    println!(
169        "{:<w_name$}  {:<w_type$}  PROVENANCE",
170        "NAME",
171        "TYPE",
172        w_name = w_name,
173        w_type = w_type,
174    );
175    println!("{}", "-".repeat(w_name + w_type + 14));
176
177    for (name, type_str, prov) in &rows {
178        println!(
179            "{:<w_name$}  {:<w_type$}  {}",
180            name,
181            type_str,
182            prov,
183            w_name = w_name,
184            w_type = w_type,
185        );
186    }
187
188    let injected = loaded.injected.len();
189    println!(
190        "\n{} asset(s) after expansion ({} injected) in {}",
191        rows.len(),
192        injected,
193        json_path
194    );
195    println!("Use `cn explain <name>` to print an entry for overriding.");
196    Ok(())
197}
198
199// Print the system schedule this world runs: the manifest gates (the same ones
200// `World::start` runs) applied to the built world, each system paired with the
201// condition from its registry entry. The world is built exactly as the runtime
202// would, so the reported schedule cannot drift from what actually runs.
203fn list_systems(content: &str, json_path: &str) -> std::io::Result<()> {
204    let mut world = crate::build_world_from_str(content)?;
205    complete(&mut world)?;
206    let lines = manifest_lines(&world);
207
208    if lines.is_empty() {
209        println!("{} runs no systems.", json_path);
210        return Ok(());
211    }
212
213    println!("{} runs {} system(s), in order:", json_path, lines.len());
214    for line in &lines {
215        println!("  {}", line);
216    }
217    Ok(())
218}
219
220// Run the table's own completion pass, the way `World::start` does before it
221// gates: a HUD or overlay the engine injects brings its own system with it, so
222// a manifest taken before the pass would be missing them.
223fn complete(world: &mut concinnity_engine::ecs::World) -> std::io::Result<()> {
224    let Some(complete) = concinnity_engine::ecs::SYSTEMS.complete_world else {
225        return Ok(());
226    };
227    complete(&mut world.context())
228        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))
229}
230
231// One "<name>  <present_when>" row per system the world's content gates in, in
232// run order. Split out from the printing so it is unit-testable without
233// capturing stdout. The reason column is the `present_when` from the static
234// schedule table (`ecs::SYSTEMS`), keyed by the manifest's system name.
235fn manifest_lines(world: &concinnity_engine::ecs::World) -> Vec<String> {
236    let manifest = world.system_manifest(concinnity_engine::ecs::SYSTEMS);
237    let width = manifest.iter().map(|n| n.len()).max().unwrap_or(0);
238    manifest
239        .iter()
240        .map(|name| {
241            let reason = concinnity_engine::ecs::SYSTEMS
242                .entries
243                .iter()
244                .find(|e| e.name == *name)
245                .map(|e| e.present_when)
246                .unwrap_or("");
247            format!("{name:<width$}  {reason}")
248        })
249        .collect()
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn write_world(content: &str) -> (tempfile::TempDir, String) {
257        let dir = tempfile::tempdir().unwrap();
258        let path = dir.path().join("world.jsonl");
259        std::fs::write(&path, content).unwrap();
260        (dir, path.to_string_lossy().into_owned())
261    }
262
263    #[test]
264    fn resolve_world_path_prefers_an_explicit_existing_path() {
265        let (_dir, path) = write_world("");
266        assert_eq!(resolve_world_path(Some(&path)).unwrap(), path);
267    }
268
269    // A minimal rendering world with a controlled camera gates in the graphics,
270    // overlay, and camera systems; each manifest line names the system and the
271    // condition that includes it.
272    #[test]
273    fn manifest_lines_report_the_world_schedule_with_reasons() {
274        let world = crate::build_world_from_str(
275            "{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n\
276             {\"name\":\"cam\",\"type\":\"Camera3D\",\"args\":{\"controller\":{\"free_fly\":true}}}\n",
277        )
278        .unwrap();
279        let lines = manifest_lines(&world);
280        let joined = lines.join("\n");
281        assert!(joined.contains("GraphicsSystem"), "{joined}");
282        assert!(joined.contains("Camera3DSystem"), "{joined}");
283        // The reason column is present (GraphicsSystem gates on a GraphicsConfig).
284        assert!(joined.contains("GraphicsConfig"), "{joined}");
285    }
286
287    // A system only an injected default turns on is still reported: the
288    // listing completes the world first, exactly as `World::start` does.
289    #[test]
290    fn the_manifest_reports_systems_the_engine_defaults_turn_on() {
291        let mut world = crate::build_world_from_str(
292            "{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n",
293        )
294        .unwrap();
295        assert!(
296            !manifest_lines(&world).join("\n").contains("DebugHud"),
297            "the world declares no DebugHud of its own"
298        );
299        complete(&mut world).unwrap();
300        assert!(
301            manifest_lines(&world).join("\n").contains("DebugHud"),
302            "the injected debug HUD brings its system with it"
303        );
304    }
305
306    // The `--systems` path drives end to end on a valid world.
307    #[test]
308    fn list_with_systems_flag_is_ok() {
309        let (_dir, path) =
310            write_world("{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n");
311        list(Some(&path), false, true).unwrap();
312    }
313
314    fn loaded_world_fixture() -> concinnity_cook::build_only::LoadedWorld {
315        concinnity_cook::build_only::LoadedWorld {
316            assets: Vec::new(),
317            injected: vec![concinnity_cook::build_only::InjectedAsset {
318                name: "debug_hud".to_string(),
319                asset_type: "DebugHud".to_string(),
320                args: serde_json::json!({}),
321                injected_by: "debug_hud",
322            }],
323            generated: vec![concinnity_cook::build_only::GeneratedAsset {
324                name: "bistro_mat_wood".to_string(),
325                asset_type: "Material".to_string(),
326                generated_by: "bistro".to_string(),
327            }],
328            shadowed: vec![concinnity_cook::build_only::ShadowedAsset {
329                name: "bistro_mat_glass".to_string(),
330                asset_type: "Material".to_string(),
331                generated_by: "bistro".to_string(),
332                args: serde_json::json!({}),
333            }],
334            authored: vec!["cam".to_string(), "bistro_mat_glass".to_string()],
335        }
336    }
337
338    #[test]
339    fn provenance_reports_authored_names() {
340        assert_eq!(provenance(&loaded_world_fixture(), "cam"), "authored");
341    }
342
343    #[test]
344    fn provenance_reports_the_injection_pass() {
345        assert_eq!(
346            provenance(&loaded_world_fixture(), "debug_hud"),
347            "injected:debug_hud"
348        );
349    }
350
351    // A generated asset names the import that produced it, so a listing of a
352    // scene import's thousands of entries stays attributable.
353    #[test]
354    fn provenance_reports_the_generating_import() {
355        assert_eq!(
356            provenance(&loaded_world_fixture(), "bistro_mat_wood"),
357            "generated:bistro"
358        );
359    }
360
361    // An authored copy of a generated asset is authored, but the listing says
362    // what it overrides: the import no longer drives that asset.
363    #[test]
364    fn provenance_reports_what_an_authored_copy_shadows() {
365        assert_eq!(
366            provenance(&loaded_world_fixture(), "bistro_mat_glass"),
367            "authored (shadows bistro)"
368        );
369    }
370
371    #[test]
372    fn provenance_falls_back_to_expanded() {
373        assert_eq!(provenance(&loaded_world_fixture(), "anything"), "expanded");
374    }
375
376    #[test]
377    fn list_of_an_empty_world_is_ok() {
378        let (_dir, path) = write_world("");
379        list(Some(&path), false, false).unwrap();
380    }
381
382    #[test]
383    fn list_prints_known_and_unknown_types() {
384        // GraphicsConfig resolves through the component registry, AudioClip
385        // through the resource-asset registry; the made-up type falls back
386        // to "?" origin / payload without erroring.
387        let (_dir, path) = write_world(concat!(
388            "{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n",
389            "{\"name\":\"clip\",\"type\":\"AudioClip\",\"args\":{}}\n",
390            "{\"name\":\"odd\",\"type\":\"NotARealAssetType\",\"args\":{}}\n",
391            "{\"type\":\"GraphicsConfig\",\"args\":{}}\n",
392        ));
393        list(Some(&path), false, false).unwrap();
394    }
395
396    #[test]
397    fn registration_for_resolves_component_types() {
398        let r = registration_for("GraphicsConfig").unwrap();
399        assert_eq!(r.type_name, "GraphicsConfig");
400    }
401
402    #[test]
403    fn registration_for_resolves_resource_assets() {
404        // A resource asset is a registered type like any other; what marks it is
405        // the handle space it reports, not a separate registry.
406        for name in ["AudioClip", "Texture"] {
407            assert!(RegisteredType::parse(name).is_some_and(|t| t.is_resource()));
408            let r = registration_for(name).unwrap();
409            assert_eq!(
410                r.origin,
411                concinnity_cook::authoring::registry::AssetOrigin::External
412            );
413            assert_eq!(
414                r.payload,
415                concinnity_cook::authoring::registry::AssetPayload::Compiled
416            );
417        }
418    }
419
420    #[test]
421    fn registration_for_unknown_type_is_none() {
422        assert!(registration_for("NotARealAssetType").is_none());
423    }
424
425    #[test]
426    fn list_with_invalid_json_errors() {
427        let (_dir, path) = write_world("{ not json\n");
428        let err = list(Some(&path), false, false).unwrap_err();
429        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
430    }
431
432    #[test]
433    fn list_expanded_runs_the_build_front_half() {
434        let (_dir, path) =
435            write_world("{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n");
436        list(Some(&path), true, false).unwrap();
437    }
438
439    #[test]
440    fn list_expanded_rejects_an_unknown_asset_type() {
441        let (_dir, path) =
442            write_world("{\"name\":\"odd\",\"type\":\"NotARealAssetType\",\"args\":{}}\n");
443        assert!(list(Some(&path), true, false).is_err());
444    }
445
446    // An unreadable world is reported rather than panicking. A directory stands
447    // in for the unreadable file: it passes the exists() check that selects the
448    // explicit path, then fails the read.
449    #[test]
450    fn list_of_an_unreadable_world_surfaces_the_read_error() {
451        let dir = tempfile::tempdir().unwrap();
452        let path = dir.path().to_string_lossy().into_owned();
453        assert!(list(Some(&path), false, false).is_err());
454    }
455
456    // An empty world expands to nothing (injection is conditional on what the
457    // world declares), so the expanded listing has no table to print.
458    #[test]
459    fn list_expanded_of_an_empty_world_is_ok() {
460        let (_dir, path) = write_world("");
461        list(Some(&path), true, false).unwrap();
462    }
463
464    // Likewise the schedule: with nothing declared, no system gates in.
465    #[test]
466    fn list_systems_of_an_empty_world_is_ok() {
467        let (_dir, path) = write_world("");
468        list(Some(&path), false, true).unwrap();
469    }
470
471    #[test]
472    fn manifest_lines_of_an_empty_world_are_empty() {
473        let world = crate::build_world_from_str("").unwrap();
474        assert!(manifest_lines(&world).is_empty());
475    }
476}