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