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(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 =
142        concinnity_cook::prepare_world(content, concinnity_cook::paths::assets_dir().as_deref())
143            .map_err(|errs| concinnity_cook::check::report_validation_errors(&errs))?;
144
145    if loaded.assets.is_empty() {
146        println!("{} expands to no assets.", json_path);
147        return Ok(());
148    }
149
150    let rows: Vec<(String, String, String)> = loaded
151        .assets
152        .iter()
153        .map(|a| {
154            (
155                a.name.clone(),
156                a.asset_type.clone(),
157                provenance(&loaded, &a.name),
158            )
159        })
160        .collect();
161
162    let w_name = rows.iter().map(|r| r.0.len()).max().unwrap_or(4).max(4);
163    let w_type = rows.iter().map(|r| r.1.len()).max().unwrap_or(4).max(4);
164
165    println!(
166        "{:<w_name$}  {:<w_type$}  PROVENANCE",
167        "NAME",
168        "TYPE",
169        w_name = w_name,
170        w_type = w_type,
171    );
172    println!("{}", "-".repeat(w_name + w_type + 14));
173
174    for (name, type_str, prov) in &rows {
175        println!(
176            "{:<w_name$}  {:<w_type$}  {}",
177            name,
178            type_str,
179            prov,
180            w_name = w_name,
181            w_type = w_type,
182        );
183    }
184
185    let injected = loaded.injected.len();
186    println!(
187        "\n{} asset(s) after expansion ({} injected) in {}",
188        rows.len(),
189        injected,
190        json_path
191    );
192    println!("Use `cn explain <name>` to print an entry for overriding.");
193    Ok(())
194}
195
196// Print the system schedule this world runs: the manifest gates (the same ones
197// `World::start` runs) applied to the built world, each system paired with the
198// condition from its registry entry. The world is built exactly as the runtime
199// would, so the reported schedule cannot drift from what actually runs.
200fn list_systems(content: &str, json_path: &str) -> std::io::Result<()> {
201    let mut world = crate::build_world_from_str(content)?;
202    complete(&mut world)?;
203    let lines = manifest_lines(&world);
204
205    if lines.is_empty() {
206        println!("{} runs no systems.", json_path);
207        return Ok(());
208    }
209
210    println!("{} runs {} system(s), in order:", json_path, lines.len());
211    for line in &lines {
212        println!("  {}", line);
213    }
214    Ok(())
215}
216
217// Run the table's own completion pass, the way `World::start` does before it
218// gates: a HUD or overlay the engine injects brings its own system with it, so
219// a manifest taken before the pass would be missing them.
220fn complete(world: &mut concinnity_engine::ecs::World) -> std::io::Result<()> {
221    let Some(complete) = concinnity_engine::ecs::SYSTEMS.complete_world else {
222        return Ok(());
223    };
224    complete(&mut world.context())
225        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))
226}
227
228// One "<name>  <present_when>" row per system the world's content gates in, in
229// run order. Split out from the printing so it is unit-testable without
230// capturing stdout. The reason column is the `present_when` from the static
231// schedule table (`ecs::SYSTEMS`), keyed by the manifest's system name.
232fn manifest_lines(world: &concinnity_engine::ecs::World) -> Vec<String> {
233    let manifest = world.system_manifest(concinnity_engine::ecs::SYSTEMS);
234    let width = manifest.iter().map(|n| n.len()).max().unwrap_or(0);
235    manifest
236        .iter()
237        .map(|name| {
238            let reason = concinnity_engine::ecs::SYSTEMS
239                .entries
240                .iter()
241                .find(|e| e.name == *name)
242                .map(|e| e.present_when)
243                .unwrap_or("");
244            format!("{name:<width$}  {reason}")
245        })
246        .collect()
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn write_world(content: &str) -> (tempfile::TempDir, String) {
254        let dir = tempfile::tempdir().unwrap();
255        let path = dir.path().join("world.jsonl");
256        std::fs::write(&path, content).unwrap();
257        (dir, path.to_string_lossy().into_owned())
258    }
259
260    #[test]
261    fn resolve_world_path_prefers_an_explicit_existing_path() {
262        let (_dir, path) = write_world("");
263        assert_eq!(resolve_world_path(Some(&path)).unwrap(), path);
264    }
265
266    // A minimal rendering world with a controlled camera gates in the graphics,
267    // overlay, and camera systems; each manifest line names the system and the
268    // condition that includes it.
269    #[test]
270    fn manifest_lines_report_the_world_schedule_with_reasons() {
271        let world = crate::build_world_from_str(
272            "{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n\
273             {\"name\":\"cam\",\"type\":\"Camera3D\",\"args\":{\"controller\":{\"free_fly\":true}}}\n",
274        )
275        .unwrap();
276        let lines = manifest_lines(&world);
277        let joined = lines.join("\n");
278        assert!(joined.contains("GraphicsSystem"), "{joined}");
279        assert!(joined.contains("Camera3DSystem"), "{joined}");
280        // The reason column is present (GraphicsSystem gates on a GraphicsConfig).
281        assert!(joined.contains("GraphicsConfig"), "{joined}");
282    }
283
284    // A system only an injected default turns on is still reported: the
285    // listing completes the world first, exactly as `World::start` does.
286    #[test]
287    fn the_manifest_reports_systems_the_engine_defaults_turn_on() {
288        let mut world = crate::build_world_from_str(
289            "{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n",
290        )
291        .unwrap();
292        assert!(
293            !manifest_lines(&world).join("\n").contains("DebugHud"),
294            "the world declares no DebugHud of its own"
295        );
296        complete(&mut world).unwrap();
297        assert!(
298            manifest_lines(&world).join("\n").contains("DebugHud"),
299            "the injected debug HUD brings its system with it"
300        );
301    }
302
303    // The `--systems` path drives end to end on a valid world.
304    #[test]
305    fn list_with_systems_flag_is_ok() {
306        let (_dir, path) =
307            write_world("{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n");
308        list(Some(&path), false, true).unwrap();
309    }
310
311    fn loaded_world_fixture() -> concinnity_cook::build_only::LoadedWorld {
312        concinnity_cook::build_only::LoadedWorld {
313            assets: Vec::new(),
314            injected: vec![concinnity_cook::build_only::InjectedAsset {
315                name: "debug_hud".to_string(),
316                asset_type: "DebugHud".to_string(),
317                args: serde_json::json!({}),
318                injected_by: "debug_hud",
319            }],
320            generated: vec![concinnity_cook::build_only::GeneratedAsset {
321                name: "bistro_mat_wood".to_string(),
322                asset_type: "Material".to_string(),
323                generated_by: "bistro".to_string(),
324            }],
325            shadowed: vec![concinnity_cook::build_only::ShadowedAsset {
326                name: "bistro_mat_glass".to_string(),
327                asset_type: "Material".to_string(),
328                generated_by: "bistro".to_string(),
329                args: serde_json::json!({}),
330            }],
331            authored: vec!["cam".to_string(), "bistro_mat_glass".to_string()],
332        }
333    }
334
335    #[test]
336    fn provenance_reports_authored_names() {
337        assert_eq!(provenance(&loaded_world_fixture(), "cam"), "authored");
338    }
339
340    #[test]
341    fn provenance_reports_the_injection_pass() {
342        assert_eq!(
343            provenance(&loaded_world_fixture(), "debug_hud"),
344            "injected:debug_hud"
345        );
346    }
347
348    // A generated asset names the import that produced it, so a listing of a
349    // scene import's thousands of entries stays attributable.
350    #[test]
351    fn provenance_reports_the_generating_import() {
352        assert_eq!(
353            provenance(&loaded_world_fixture(), "bistro_mat_wood"),
354            "generated:bistro"
355        );
356    }
357
358    // An authored copy of a generated asset is authored, but the listing says
359    // what it overrides: the import no longer drives that asset.
360    #[test]
361    fn provenance_reports_what_an_authored_copy_shadows() {
362        assert_eq!(
363            provenance(&loaded_world_fixture(), "bistro_mat_glass"),
364            "authored (shadows bistro)"
365        );
366    }
367
368    #[test]
369    fn provenance_falls_back_to_expanded() {
370        assert_eq!(provenance(&loaded_world_fixture(), "anything"), "expanded");
371    }
372
373    #[test]
374    fn list_of_an_empty_world_is_ok() {
375        let (_dir, path) = write_world("");
376        list(Some(&path), false, false).unwrap();
377    }
378
379    #[test]
380    fn list_prints_known_and_unknown_types() {
381        // GraphicsConfig resolves through the component registry, AudioClip
382        // through the resource-asset registry; the made-up type falls back
383        // to "?" origin / payload without erroring.
384        let (_dir, path) = write_world(concat!(
385            "{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n",
386            "{\"name\":\"clip\",\"type\":\"AudioClip\",\"args\":{}}\n",
387            "{\"name\":\"odd\",\"type\":\"NotARealAssetType\",\"args\":{}}\n",
388            "{\"type\":\"GraphicsConfig\",\"args\":{}}\n",
389        ));
390        list(Some(&path), false, false).unwrap();
391    }
392
393    #[test]
394    fn registration_for_resolves_component_types() {
395        let r = registration_for("GraphicsConfig").unwrap();
396        assert_eq!(r.type_name, "GraphicsConfig");
397    }
398
399    #[test]
400    fn registration_for_resolves_resource_assets() {
401        // A resource asset is a registered type like any other; what marks it is
402        // the handle space it reports, not a separate registry.
403        for name in ["AudioClip", "Texture"] {
404            assert!(RegisteredType::parse(name).is_some_and(|t| t.is_resource()));
405            let r = registration_for(name).unwrap();
406            assert_eq!(
407                r.origin,
408                concinnity_cook::authoring::registry::AssetOrigin::External
409            );
410            assert_eq!(
411                r.payload,
412                concinnity_cook::authoring::registry::AssetPayload::Compiled
413            );
414        }
415    }
416
417    #[test]
418    fn registration_for_unknown_type_is_none() {
419        assert!(registration_for("NotARealAssetType").is_none());
420    }
421
422    #[test]
423    fn list_with_invalid_json_errors() {
424        let (_dir, path) = write_world("{ not json\n");
425        let err = list(Some(&path), false, false).unwrap_err();
426        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
427    }
428
429    #[test]
430    fn list_expanded_runs_the_build_front_half() {
431        let (_dir, path) =
432            write_world("{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n");
433        list(Some(&path), true, false).unwrap();
434    }
435
436    #[test]
437    fn list_expanded_rejects_an_unknown_asset_type() {
438        let (_dir, path) =
439            write_world("{\"name\":\"odd\",\"type\":\"NotARealAssetType\",\"args\":{}}\n");
440        assert!(list(Some(&path), true, false).is_err());
441    }
442
443    // An unreadable world is reported rather than panicking. A directory stands
444    // in for the unreadable file: it passes the exists() check that selects the
445    // explicit path, then fails the read.
446    #[test]
447    fn list_of_an_unreadable_world_surfaces_the_read_error() {
448        let dir = tempfile::tempdir().unwrap();
449        let path = dir.path().to_string_lossy().into_owned();
450        assert!(list(Some(&path), false, false).is_err());
451    }
452
453    // An empty world expands to nothing (injection is conditional on what the
454    // world declares), so the expanded listing has no table to print.
455    #[test]
456    fn list_expanded_of_an_empty_world_is_ok() {
457        let (_dir, path) = write_world("");
458        list(Some(&path), true, false).unwrap();
459    }
460
461    // Likewise the schedule: with nothing declared, no system gates in.
462    #[test]
463    fn list_systems_of_an_empty_world_is_ok() {
464        let (_dir, path) = write_world("");
465        list(Some(&path), false, true).unwrap();
466    }
467
468    #[test]
469    fn manifest_lines_of_an_empty_world_are_empty() {
470        let world = crate::build_world_from_str("").unwrap();
471        assert!(manifest_lines(&world).is_empty());
472    }
473}