Skip to main content

concinnity_dev/command/
list.rs

1// src/cli/list.rs
2use concinnity_cook::world::{find_world_jsonl, parse_world_jsonl, resolve_includes};
3use concinnity_world::registry::RegisteredType;
4
5// Authoring metadata for a type name, whichever group of the registry it is in.
6fn registration_for(type_str: &str) -> Option<concinnity_world::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::world::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 world = crate::build_world_from_str(content)?;
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// One "<name>  <present_when>" row per system the world's content gates in, in
217// run order. Split out from the printing so it is unit-testable without
218// capturing stdout. The reason column is the `present_when` from the static
219// schedule table (`ecs::SYSTEMS`), keyed by the manifest's system name.
220fn manifest_lines(world: &concinnity_engine::ecs::World) -> Vec<String> {
221    let manifest = world.system_manifest(concinnity_engine::ecs::SYSTEMS);
222    let width = manifest.iter().map(|n| n.len()).max().unwrap_or(0);
223    manifest
224        .iter()
225        .map(|name| {
226            let reason = concinnity_engine::ecs::SYSTEMS
227                .entries
228                .iter()
229                .find(|e| e.name == *name)
230                .map(|e| e.present_when)
231                .unwrap_or("");
232            format!("{name:<width$}  {reason}")
233        })
234        .collect()
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn write_world(content: &str) -> (tempfile::TempDir, String) {
242        let dir = tempfile::tempdir().unwrap();
243        let path = dir.path().join("world.jsonl");
244        std::fs::write(&path, content).unwrap();
245        (dir, path.to_string_lossy().into_owned())
246    }
247
248    #[test]
249    fn resolve_world_path_prefers_an_explicit_existing_path() {
250        let (_dir, path) = write_world("");
251        assert_eq!(resolve_world_path(Some(&path)).unwrap(), path);
252    }
253
254    // A minimal rendering world with a controlled camera gates in the graphics,
255    // overlay, and camera systems; each manifest line names the system and the
256    // condition that includes it.
257    #[test]
258    fn manifest_lines_report_the_world_schedule_with_reasons() {
259        let world = crate::build_world_from_str(
260            "{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n\
261             {\"name\":\"cam\",\"type\":\"Camera3D\",\"args\":{\"controller\":{\"free_fly\":true}}}\n",
262        )
263        .unwrap();
264        let lines = manifest_lines(&world);
265        let joined = lines.join("\n");
266        assert!(joined.contains("GraphicsSystem"), "{joined}");
267        assert!(joined.contains("Camera3DSystem"), "{joined}");
268        // The reason column is present (GraphicsSystem gates on a GraphicsConfig).
269        assert!(joined.contains("GraphicsConfig"), "{joined}");
270    }
271
272    // The `--systems` path drives end to end on a valid world.
273    #[test]
274    fn list_with_systems_flag_is_ok() {
275        let (_dir, path) =
276            write_world("{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n");
277        list(Some(&path), false, true).unwrap();
278    }
279
280    fn loaded_world_fixture() -> concinnity_cook::world::LoadedWorld {
281        concinnity_cook::world::LoadedWorld {
282            assets: Vec::new(),
283            injected: vec![concinnity_cook::world::InjectedAsset {
284                name: "debug_hud".to_string(),
285                asset_type: "DebugHud".to_string(),
286                args: serde_json::json!({}),
287                injected_by: "debug_hud",
288            }],
289            generated: vec![concinnity_cook::world::GeneratedAsset {
290                name: "bistro_mat_wood".to_string(),
291                asset_type: "Material".to_string(),
292                generated_by: "bistro".to_string(),
293            }],
294            shadowed: vec![concinnity_cook::world::ShadowedAsset {
295                name: "bistro_mat_glass".to_string(),
296                asset_type: "Material".to_string(),
297                generated_by: "bistro".to_string(),
298                args: serde_json::json!({}),
299            }],
300            authored: vec!["cam".to_string(), "bistro_mat_glass".to_string()],
301        }
302    }
303
304    #[test]
305    fn provenance_reports_authored_names() {
306        assert_eq!(provenance(&loaded_world_fixture(), "cam"), "authored");
307    }
308
309    #[test]
310    fn provenance_reports_the_injection_pass() {
311        assert_eq!(
312            provenance(&loaded_world_fixture(), "debug_hud"),
313            "injected:debug_hud"
314        );
315    }
316
317    // A generated asset names the import that produced it, so a listing of a
318    // scene import's thousands of entries stays attributable.
319    #[test]
320    fn provenance_reports_the_generating_import() {
321        assert_eq!(
322            provenance(&loaded_world_fixture(), "bistro_mat_wood"),
323            "generated:bistro"
324        );
325    }
326
327    // An authored copy of a generated asset is authored, but the listing says
328    // what it overrides: the import no longer drives that asset.
329    #[test]
330    fn provenance_reports_what_an_authored_copy_shadows() {
331        assert_eq!(
332            provenance(&loaded_world_fixture(), "bistro_mat_glass"),
333            "authored (shadows bistro)"
334        );
335    }
336
337    #[test]
338    fn provenance_falls_back_to_expanded() {
339        assert_eq!(provenance(&loaded_world_fixture(), "anything"), "expanded");
340    }
341
342    #[test]
343    fn list_of_an_empty_world_is_ok() {
344        let (_dir, path) = write_world("");
345        list(Some(&path), false, false).unwrap();
346    }
347
348    #[test]
349    fn list_prints_known_and_unknown_types() {
350        // GraphicsConfig resolves through the component registry, AudioClip
351        // through the resource-asset registry; the made-up type falls back
352        // to "?" origin / payload without erroring.
353        let (_dir, path) = write_world(concat!(
354            "{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n",
355            "{\"name\":\"clip\",\"type\":\"AudioClip\",\"args\":{}}\n",
356            "{\"name\":\"odd\",\"type\":\"NotARealAssetType\",\"args\":{}}\n",
357            "{\"type\":\"GraphicsConfig\",\"args\":{}}\n",
358        ));
359        list(Some(&path), false, false).unwrap();
360    }
361
362    #[test]
363    fn registration_for_resolves_component_types() {
364        let r = registration_for("GraphicsConfig").unwrap();
365        assert_eq!(r.type_name, "GraphicsConfig");
366    }
367
368    #[test]
369    fn registration_for_resolves_resource_assets() {
370        // A resource asset is a registered type like any other; what marks it is
371        // the handle space it reports, not a separate registry.
372        for name in ["AudioClip", "Texture"] {
373            assert!(RegisteredType::parse(name).is_some_and(|t| t.is_resource()));
374            let r = registration_for(name).unwrap();
375            assert_eq!(r.origin, concinnity_world::registry::AssetOrigin::External);
376            assert_eq!(
377                r.payload,
378                concinnity_world::registry::AssetPayload::Compiled
379            );
380        }
381    }
382
383    #[test]
384    fn registration_for_unknown_type_is_none() {
385        assert!(registration_for("NotARealAssetType").is_none());
386    }
387
388    #[test]
389    fn list_with_invalid_json_errors() {
390        let (_dir, path) = write_world("{ not json\n");
391        let err = list(Some(&path), false, false).unwrap_err();
392        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
393    }
394
395    #[test]
396    fn list_expanded_runs_the_build_front_half() {
397        let (_dir, path) =
398            write_world("{\"name\":\"gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}\n");
399        list(Some(&path), true, false).unwrap();
400    }
401
402    #[test]
403    fn list_expanded_rejects_an_unknown_asset_type() {
404        let (_dir, path) =
405            write_world("{\"name\":\"odd\",\"type\":\"NotARealAssetType\",\"args\":{}}\n");
406        assert!(list(Some(&path), true, false).is_err());
407    }
408
409    // An unreadable world is reported rather than panicking. A directory stands
410    // in for the unreadable file: it passes the exists() check that selects the
411    // explicit path, then fails the read.
412    #[test]
413    fn list_of_an_unreadable_world_surfaces_the_read_error() {
414        let dir = tempfile::tempdir().unwrap();
415        let path = dir.path().to_string_lossy().into_owned();
416        assert!(list(Some(&path), false, false).is_err());
417    }
418
419    // An empty world expands to nothing (injection is conditional on what the
420    // world declares), so the expanded listing has no table to print.
421    #[test]
422    fn list_expanded_of_an_empty_world_is_ok() {
423        let (_dir, path) = write_world("");
424        list(Some(&path), true, false).unwrap();
425    }
426
427    // Likewise the schedule: with nothing declared, no system gates in.
428    #[test]
429    fn list_systems_of_an_empty_world_is_ok() {
430        let (_dir, path) = write_world("");
431        list(Some(&path), false, true).unwrap();
432    }
433
434    #[test]
435    fn manifest_lines_of_an_empty_world_are_empty() {
436        let world = crate::build_world_from_str("").unwrap();
437        assert!(manifest_lines(&world).is_empty());
438    }
439}