rproj 0.20.1

Guided bootstrap-to-game-dev CLI for Roblox: takes a fresh Windows PC to a working Roblox/Luau setup, then scaffolds projects on it
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
use std::collections::BTreeSet;
use std::path::Path;

use anyhow::{Context, Result, bail};
use inquire::{MultiSelect, Select};

use crate::catalog::artifacts;
use crate::catalog::capabilities;
use crate::catalog::tool_catalog::{self, ToolKind};
use crate::catalog::tool_settings;
use crate::catalog::wally_packages::{self, Category, PackageSpec, companions_for};
use crate::commands::provision;
use crate::config::{GlobalConfig, PackageWorkflow, Setups, project_file, project_template};
use crate::graph::{Node, ProjectGraph, TestRunner};
use crate::steps::{
    asphalt, blender, figma, git, gitattributes, gitignore, jest, modules, quality, rojo,
    studio_plugin, testez, toolchain, tungsten, vscode, wally,
};
use crate::ui;

pub fn run(
    name: &str,
    reconfigure: bool,
    like: Option<&str>,
    save_setup: Option<&str>,
) -> Result<()> {
    let mut config = GlobalConfig::load()?;

    let (project_dir, project_template) = prepare_project(&config, name)?;

    // Resolve --like before doing any work, so a typo'd setup name fails
    // immediately rather than after provisioning and half a scaffold.
    let saved = match like {
        Some(setup) => Some(load_setup(setup)?),
        None => None,
    };

    // Machine setup is a once-per-PC concern, so it's only asked when
    // there's nothing recorded yet (a genuinely fresh machine) or when
    // explicitly requested. Re-asking four multi-selects about winget
    // packages on every new project was pure friction: the answers are
    // almost never different, and `rproj new` is a project command.
    if reconfigure || !config.machine_configured() {
        if !config.machine_configured() {
            println!("Setting your machine up first - this only happens once.\n");
        }
        provision::run(&mut config)?;
    } else {
        ui::ok(&format!("machine ready: {}", config.machine_summary()));
        ui::detail("rproj setup to re-check, or rproj new --reconfigure to change");
    }

    ui::section(&format!("Scaffolding {name}"));
    ui::detail(&project_dir.display().to_string());

    // The order below is the whole redesign. Each answer narrows the next,
    // and no prompt asks about a consequence of a decision made after it:
    //
    //   strategy -> packages -> capabilities -> summary
    //
    // Strategy first because it decides which packages can even be
    // vendored. It used to come *after* packages, which meant picking React
    // silently overruled the user's architecture - the one decision that
    // should be theirs was the one rproj made for them.
    let mut graph = match saved {
        Some((setup_name, setup)) => {
            ui::ok(&format!(
                "using saved setup `{setup_name}`: {}",
                if setup.packages.is_empty() {
                    "no packages".to_string()
                } else {
                    setup.packages.join(", ")
                }
            ));
            // The whole composition replays. It used to reuse the packages
            // and then ask three more questions, which is "reuse my setup"
            // half kept.
            ProjectGraph {
                mode: format!("like:{setup_name}"),
                ..setup
            }
        }
        None => ask_for_graph()?,
    };

    // A capability can pull in a package - `test` brings TestEZ - which is
    // why testing left the package picker. Applied after the graph is built
    // because it is a consequence, not an answer.
    apply_derived_packages(&mut graph);

    let (apps, extensions) = (
        config.selected_system_apps.clone(),
        config.selected_vscode_extensions.clone(),
    );

    // Revision, not navigation. Every branch re-derives from the graph, so
    // the summary can never describe a project different from the one that
    // gets written - and changing an early answer invalidates exactly what
    // it makes stale (`graph::Node::invalidates`) rather than everything
    // after it.
    let planned = loop {
        let planned = graph.plan(&apps, &extensions);
        match confirm_plan(name, &graph, &planned)? {
            Outcome::Create => break planned,
            Outcome::Customize => {
                graph.dropped =
                    customize_plan(&graph.full_plan(&apps, &extensions), &graph.dropped)?;
            }
            Outcome::Change => {
                revise(&mut graph)?;
                // Re-applied because a capability may have been added or
                // removed, and `test` owns a package.
                apply_derived_packages(&mut graph);
            }
            Outcome::Cancel => {
                crate::diagnostics::event("new.cancel", "nothing created");
                println!("\nNothing created.");
                return Ok(());
            }
        }
    };

    execute_confirmed(
        name,
        &project_dir,
        &graph,
        &planned,
        project_template.as_ref(),
        &mut config,
        save_setup.map(SetupSave::Replace),
    )
}

pub(super) fn prepare_project(
    config: &GlobalConfig,
    name: &str,
) -> Result<(std::path::PathBuf, Option<serde_json::Value>)> {
    let project_dir = config.projects_root()?.join(name);
    crate::diagnostics::event("new.destination", project_dir.display().to_string());
    if project_dir.exists() {
        bail!("{} already exists", project_dir.display());
    }
    // Validate before provisioning or claiming a project directory.
    let template = project_template::load()?;
    if let Some(template) = &template {
        rojo::validate_template_with_rojo(template).context(
            "the saved project template is invalid; run `rproj configure project` to repair or reset it",
        )?;
    }
    Ok((project_dir, template))
}

pub(super) enum SetupSave<'a> {
    Replace(&'a str),
    New(&'a str),
}

/// Executes an already reviewed composition without asking any questions.
/// Callers must validate templates and setup compatibility before review, and
/// obtain explicit Create confirmation and restore the terminal before calling.
/// The supplied plan must be the one reviewed for this graph and machine.
/// Destination ownership is rechecked before scaffolding or saving any setup.
pub(super) fn execute_confirmed(
    name: &str,
    project_dir: &Path,
    graph: &ProjectGraph,
    planned: &[artifacts::Planned],
    project_template: Option<&serde_json::Value>,
    config: &mut GlobalConfig,
    save_setup: Option<SetupSave<'_>>,
) -> Result<()> {
    crate::interrupt::check()?;
    create_project_dir(project_dir)?;
    let result = (|| {
        let packages = graph.package_set();
        let package_workflow = graph.package_workflow;
        let project_tools = graph.tools();
        let test_runner = graph.test_runner();
        let chosen_artifacts: Vec<String> = planned.iter().map(|p| p.key.to_string()).collect();
        scaffold(
            project_dir,
            name,
            ScaffoldOptions {
                packages: &packages,
                package_workflow,
                project_tools: &project_tools,
                test_runner,
                jest_backend: graph.jest_backend(),
                chosen_artifacts: &chosen_artifacts,
                project_template,
            },
        )?;

        crate::interrupt::check()?;

        // Written here rather than in `scaffold`, because it records the mode
        // and the saved-setup name, which belong to the reviewed graph. Gated on the
        // same artifact key so declining it declines it - the cost being that
        // `rproj upgrade` then has nothing to read, which its own error says.
        let writes = |key: &str| chosen_artifacts.iter().any(|k| k == key);
        if writes("rproj.toml") {
            // The graph itself, decisions and all - not a summary of what came
            // out. That is what lets `rproj upgrade` re-derive from intent, so a
            // changed default reaches a project made months ago instead of
            // stranding it.
            project_file::save_to(graph, project_dir)?;
        } else {
            ui::skip("rproj.toml not written, so `rproj upgrade` won't know this project");
        }

        if let Some(save) = save_setup {
            crate::interrupt::check()?;
            // The whole graph, so `--like` replays the composition rather than
            // reusing the packages and asking three more questions.
            let setup_name = match save {
                SetupSave::Replace(name) => {
                    Setups::save(graph, name)?;
                    name
                }
                SetupSave::New(name) => {
                    Setups::save_new(graph, name)?;
                    name
                }
            };
            ui::ok(&format!(
                "saved setup `{setup_name}` - reuse with `rproj new <name> --like {setup_name}`"
            ));
        }

        if test_runner == Some(TestRunner::JestRoblox)
            && graph.jest_backend() == crate::graph::JestBackend::Studio
        {
            crate::interrupt::check()?;
            if !config
                .selected_studio_plugins
                .iter()
                .any(|key| key == "jest-roblox-plugin")
            {
                config
                    .selected_studio_plugins
                    .push("jest-roblox-plugin".into());
                config.save()?;
            }
            if let Err(error) = refresh_jest_plugin() {
                ui::warn(&format!(
                    "Jest Roblox Studio runner was not updated - {error}. Run `rproj setup` to retry"
                ));
            }
        }

        // A new project is exactly when someone doesn't yet know what to run,
        // so end with the next steps rather than just "done".
        crate::interrupt::check()?;
        let (party, folder, eye, gate, book) = ("🎉  ", "📁", "👀", "🧪", "📖");
        println!(
            "\n{party}{name} is ready.\n\n\
         \x20 {folder}  cd {}\n\
         \x20 {eye}  rproj watch          start the dev loop (Rojo sourcemap watcher)\n\
         \x20 {gate}  lute run check       run the quality gate (types, lint, format)\n\
         \x20 {book}  rproj info <tool>    what a tool does, and the commands to use it\n",
            project_dir.display()
        );
        Ok(())
    })();
    if result.is_err() {
        ui::warn(&format!(
            "Project creation did not finish. Files remain at {} and may be incomplete. Inspect this directory before retrying; rproj will not overwrite it.",
            project_dir.display()
        ));
    }
    result
}

fn create_project_dir(path: &Path) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    // Claim the destination only after confirmation; never reuse a directory
    // another process created while the user was answering prompts.
    std::fs::create_dir(path)
        .with_context(|| format!("failed to create new project at {}", path.display()))
}

fn refresh_jest_plugin() -> Result<()> {
    let entry = tool_catalog::find("jest-roblox-plugin")
        .context("Jest Roblox Studio runner is missing from the tool catalog")?;
    let ToolKind::StudioPlugin {
        github_repo,
        asset_suffix,
    } = entry.kind
    else {
        bail!("Jest Roblox Studio runner has an invalid catalog installer");
    };
    studio_plugin::refresh_from_latest_release(entry.key, github_repo, asset_suffix)
}

/// Builds the graph by asking each node in order.
///
/// One function per node, and each takes only what the nodes *before* it
/// decided. That is the design principle made structural: a prompt cannot
/// ask about a consequence of a later answer because it cannot see one.
fn ask_for_graph() -> Result<ProjectGraph> {
    let mut graph = ProjectGraph::default();
    ask_node(&mut graph, Node::Strategy)?;
    ask_node(&mut graph, Node::Packages)?;
    ask_node(&mut graph, Node::Capabilities)?;
    Ok(graph)
}

/// Asks one node and clears whatever that makes stale.
///
/// The invalidation is the whole reason revision is tractable: re-answering
/// the strategy clears the packages (some may no longer be vendorable) and
/// the file list, and leaves the capabilities alone. Nothing here decides
/// *what* goes stale - `Node::invalidates` does, in four rows.
fn ask_node(graph: &mut ProjectGraph, node: Node) -> Result<()> {
    crate::diagnostics::event("prompt.node", node.label());
    graph.invalidate(node);
    match node {
        Node::Strategy => {
            graph.package_workflow = pick_strategy()?;
            if graph.remove_incompatible_testing() {
                ui::warn(
                    "Jest Roblox requires Wally, so the previous testing choice no longer applies",
                );
                let choice = Select::new(
                    "Testing:",
                    owned(&[
                        "testez - keep testing with TestEZ",
                        "none - disable testing",
                    ]),
                )
                .with_formatter(&ui::compact_select_answer)
                .prompt()?;
                if ui::option_key(&choice) == "testez" {
                    graph.choose("test", Some("testez"));
                }
            }
        }
        Node::Packages => {
            let (mode, packages) = match graph.package_workflow {
                // Nothing to compose. Asking "which packages?" right after
                // being told there is no package manager is the same class
                // of question this redesign exists to remove.
                PackageWorkflow::None => ("none", BTreeSet::new()),
                workflow => pick_composition(workflow)?,
            };
            let (workflow, packages) = reconcile_strategy(graph.package_workflow, packages)?;
            graph.mode = mode.to_string();
            graph.package_workflow = workflow;
            graph.packages = resolve_dependencies(workflow, packages)
                .into_iter()
                .collect();
        }
        Node::Capabilities => {
            graph.capabilities.clear();
            for (key, implementation) in pick_capabilities(graph.package_workflow)? {
                graph.choose(&key, implementation.as_deref());
            }
        }
        // Handled by `customize_plan`, which needs the plan rather than the
        // graph alone.
        Node::Files => {}
    }
    Ok(())
}

/// Which earlier answer to change, then re-ask it.
///
/// This is "change something" as *invalidation* rather than navigation:
/// there is no back stack, no screen count, and no way to end up with a
/// graph whose later nodes contradict an earlier one - because re-answering
/// a node clears what it made stale before the next question is asked.
fn revise(graph: &mut ProjectGraph) -> Result<()> {
    // Derived from `Node::ALL` rather than repeated, so adding a node to the
    // graph cannot leave it uneditable. `Files` is excluded because
    // `customize` already edits it, with the plan in hand.
    let editable: Vec<Node> = Node::ALL
        .into_iter()
        .filter(|n| *n != Node::Files)
        .collect();
    let options: Vec<String> = editable
        .iter()
        .map(|node| ui::option_line(node.label(), &describe_node(graph, *node), "change"))
        .collect();

    let picked = Select::new("Change which answer?", options)
        .with_formatter(&ui::compact_select_answer)
        .prompt()?;
    let Some(node) = editable
        .iter()
        .find(|node| ui::option_is(&picked, node.label()))
    else {
        return Ok(());
    };

    // Say what else is about to be re-asked, before re-asking it. A prompt
    // that silently discards two later answers is the "go back three
    // screens and lose your work" behaviour this replaces.
    let stale: Vec<&str> = node
        .invalidates()
        .iter()
        .filter(|n| **n != Node::Files)
        .map(|n| n.label())
        .collect();
    if !stale.is_empty() {
        ui::detail(&format!("changing this re-asks: {}", stale.join(", ")));
    }

    ask_node(graph, *node)?;
    // Re-ask whatever that invalidated, in order, so the graph is complete
    // again before the summary re-renders.
    for stale in node.invalidates() {
        if *stale != Node::Files {
            ask_node(graph, *stale)?;
        }
    }
    Ok(())
}

/// One line describing a node's current answer, for the revision menu.
fn describe_node(graph: &ProjectGraph, node: Node) -> String {
    match node {
        Node::Strategy => match graph.package_workflow {
            PackageWorkflow::Wally => "Wally".to_string(),
            PackageWorkflow::GitSubmodules => "git submodules".to_string(),
            PackageWorkflow::None => "none".to_string(),
        },
        Node::Packages => {
            if graph.packages.is_empty() {
                "none".to_string()
            } else {
                graph.packages.join(", ")
            }
        }
        Node::Capabilities => {
            if graph.capabilities.is_empty() {
                "nothing extra".to_string()
            } else {
                graph.capability_keys().join(", ")
            }
        }
        Node::Files => "the generated files".to_string(),
    }
}

/// Git submodules have no dependency resolution: the scaffold clones exactly
/// the list it is given. Wally does its own, so its manifest is left as the
/// user picked it.
fn resolve_dependencies(workflow: PackageWorkflow, packages: BTreeSet<String>) -> BTreeSet<String> {
    if workflow != PackageWorkflow::GitSubmodules {
        return packages;
    }
    let resolved = wally_packages::with_dependencies(&packages);
    let added: Vec<&str> = resolved
        .iter()
        .filter(|k| !packages.contains(*k))
        .map(String::as_str)
        .collect();
    if !added.is_empty() {
        ui::ok(&format!(
            "added required dependencies: {}",
            added.join(", ")
        ));
    }
    resolved
}

/// How this project's dependencies arrive.
///
/// **Asked first, and deliberately still asked at all.** Hiding it behind a
/// flag and defaulting to Wally is technically correct - Wally is the answer
/// for anyone who does not already know otherwise - and it was rejected: a
/// summary line reading `via Wally` is a receipt, not an explanation, and
/// this is the only place a newcomer meets the concept. A default does not
/// make a question fake.
///
/// It comes before packages because it decides which packages can be
/// vendored at all. Asked after, as it used to be, selecting React silently
/// overruled the answer.
fn pick_strategy() -> Result<PackageWorkflow> {
    let choice = Select::new(
        "How should this project get its dependencies?",
        owned(&[
            "wally - Roblox's package manager. Recommended for almost every project.",
            "git-submodules - vendor each package's own repo into the project.",
            "none - no dependency manager. A tutorial project, or one you'll wire up yourself.",
        ]),
    )
    .with_formatter(&ui::compact_select_answer)
    .prompt()?;

    Ok(match ui::option_key(&choice) {
        "git-submodules" => PackageWorkflow::GitSubmodules,
        "none" => PackageWorkflow::None,
        _ => PackageWorkflow::Wally,
    })
}

/// Owns a fixed option list.
///
/// inquire types its formatter on the option type, and every picker here
/// shares one formatter (`ui::compact_select_answer`, which echoes just the
/// key). Owning the strings keeps that single formatter usable rather than
/// needing a `&str` twin of it.
fn owned(options: &[&str]) -> Vec<String> {
    options.iter().map(|s| (*s).to_string()).collect()
}

/// What this project should *do* - and, in the badge slot, what does it.
///
/// One prompt where there used to be two ("Tools to pin" and "Files to
/// generate"), because those asked the same decision at two levels below the
/// one the user thinks in. The implementation is always named: a capability
/// that hides its tool teaches nothing about the ecosystem, and someone who
/// later asks "how do I configure this?" needs to have seen the word Selene.
///
/// Returns `(capability, implementation)` pairs. The implementation is
/// `None` wherever there is only one; `asset-pipeline` names its provider
/// because Asphalt and Tungsten are real alternatives.
fn pick_capabilities(workflow: PackageWorkflow) -> Result<Vec<(String, Option<String>)>> {
    crate::diagnostics::event("prompt", "project capabilities");
    let offerable: Vec<&'static capabilities::Capability> =
        capabilities::CAPABILITIES.iter().collect();

    let options: Vec<String> = offerable
        .iter()
        .map(|c| {
            let badge = if c.key == "test" && c.needs_an_implementation_prompt(workflow) {
                "choose runner"
            } else {
                c.default_implementation().display
            };
            ui::option_line(c.key, c.outcome, badge)
        })
        .collect();
    let picked = MultiSelect::new("What should this project do?", options)
        .with_help_message(ui::MULTISELECT_HELP)
        .with_formatter(&ui::compact_multi_answer)
        .prompt()?;

    let mut chosen: Vec<(String, Option<String>)> = Vec::new();
    for capability in &offerable {
        if !picked.iter().any(|p| ui::option_is(p, capability.key)) {
            continue;
        }
        // A capability whose requirement was not chosen contributes nothing
        // (see `capabilities::derive`), so say so rather than letting the
        // user believe they enabled it.
        if !capability
            .missing_requirements(|key| chosen.iter().any(|(chosen, _)| chosen == key))
            .is_empty()
        {
            ui::skip(&format!(
                "{} needs {} - skipping it",
                capability.key,
                capability.requires.join(", ")
            ));
            continue;
        }

        let mut implementation = if capability.needs_an_implementation_prompt(workflow) {
            let implementations = capability.implementation_choices(workflow);
            let options: Vec<String> = implementations
                .iter()
                .map(|i| ui::option_line(i.key, i.display, capability.key))
                .collect();
            let picked = Select::new(&format!("{}:", capability.key), options)
                .with_formatter(&ui::compact_select_answer)
                .prompt()?;
            Some(ui::option_key(&picked).to_string())
        } else {
            capability
                .implementation_choices(workflow)
                .first()
                .map(|implementation| implementation.key.to_string())
        };
        if implementation
            .as_deref()
            .is_some_and(|key| capability.needs_jest_backend(key))
        {
            let backend = Select::new(
                "Jest execution:",
                vec![
                    "Local Studio (test before pushing; no CI tests)",
                    "Open Cloud (tests locally and in CI; Roblox credentials required)",
                ],
            )
            .prompt()?;
            if backend.starts_with("Open Cloud") {
                implementation = Some("jest-roblox-open-cloud".into());
            }
        }
        chosen.push((capability.key.to_string(), implementation));
    }
    Ok(chosen)
}

fn pick_composition(workflow: PackageWorkflow) -> Result<(&'static str, BTreeSet<String>)> {
    // Said *before* the picker rather than after the selection. Under git
    // submodules the react family cannot be vendored at all, and silently
    // omitting them is how a user ends up wondering where React went.
    let unavailable = unvendorable_keys();
    if workflow == PackageWorkflow::GitSubmodules && !unavailable.is_empty() {
        ui::detail(&format!(
            "Not listed: {}.\nUpstream ships these only through an npm install step, which git\nsubmodules can't reproduce.",
            unavailable.join(", ")
        ));
    }

    let mode = Select::new(
        "How do you want to pick packages?",
        owned(&[
            "guided - one question per category, with explanations (recommended)",
            "expert - one flat list of everything, no hand-holding",
        ]),
    )
    .with_formatter(&ui::compact_select_answer)
    .prompt()?;

    if ui::option_key(&mode) == "guided" {
        Ok(("guided", pick_guided(workflow)?))
    } else {
        Ok(("expert", pick_expert(workflow)?))
    }
}

/// Packages a capability brings in on its own, so no picker offers them.
///
/// TestEZ is the case: *do I want tests* is a capability question, and it
/// used to be asked in the package step **and** consequenced four prompts
/// later in the files step - one intent, two prompts. Derived from the
/// capability catalog rather than flagged on the package, so the two cannot
/// drift apart.
fn capability_owned(key: &str) -> bool {
    capabilities::CAPABILITIES
        .iter()
        .any(|c| c.implementations.iter().any(|i| i.packages.contains(&key)))
}

pub(super) fn apply_derived_packages(graph: &mut ProjectGraph) {
    graph.packages.retain(|key| !capability_owned(key));
    for key in graph.derived().packages {
        if !graph.packages.contains(&key) {
            graph.packages.push(key);
        }
    }
    graph.packages.sort();
}

/// Keys with no vendorable source, for the note above the package picker.
fn unvendorable_keys() -> Vec<&'static str> {
    wally_packages::PACKAGES
        .iter()
        .filter(|p| p.submodule.is_none() && !capability_owned(p.key))
        .map(|p| p.key)
        .collect()
}

/// Whether a package may be offered at all, given the strategy already
/// chosen. Offering one that cannot be installed is offering a broken
/// project.
pub(super) fn offerable_package(spec: &PackageSpec, workflow: PackageWorkflow) -> bool {
    !capability_owned(spec.key)
        && (workflow != PackageWorkflow::GitSubmodules || spec.submodule.is_some())
}

/// The summary, and the last chance to back out.
///
/// Not a picker. Every line here is already determined by an earlier answer,
/// so asking about them again would be the defect this redesign removed -
/// and every line carries **why**, because files are the one thing a
/// beginner will actually open and edit, and a list with no reasons is a
/// receipt rather than an explanation.
fn confirm_plan(
    name: &str,
    graph: &ProjectGraph,
    planned: &[artifacts::Planned],
) -> Result<Outcome> {
    crate::diagnostics::event(
        "new.review",
        format!(
            "workflow={:?}; packages={:?}; capabilities={:?}; dropped={:?}",
            graph.package_workflow, graph.packages, graph.capabilities, graph.dropped
        ),
    );
    crate::diagnostics::event("prompt", "Create it?");
    println!();
    for line in summary_lines(name, graph, planned) {
        println!("{line}");
    }
    println!();

    let choice = Select::new(
        "Create it?",
        owned(&[
            "create - go ahead and scaffold this",
            "change - go back to an earlier answer",
            "customize - drop individual files first",
            "cancel - change nothing and exit",
        ]),
    )
    .with_formatter(&ui::compact_select_answer)
    .prompt()?;
    match ui::option_key(&choice) {
        "create" => Ok(Outcome::Create),
        "change" => Ok(Outcome::Change),
        "customize" => Ok(Outcome::Customize),
        _ => Ok(Outcome::Cancel),
    }
}

/// What the user chose at the summary.
#[derive(PartialEq, Eq)]
enum Outcome {
    Create,
    /// Go back to an earlier answer. Revision, not navigation - see
    /// `graph::Node::invalidates`.
    Change,
    Customize,
    Cancel,
}

/// The escape hatch, and the reason the summary can be a summary.
///
/// Every line on it is already determined, so re-asking about them would be
/// the defect this redesign removed. But "determined" is not "immutable":
/// the housekeeping entries in particular (`rproj.toml`, `.gitignore`) are
/// written for every project on the grounds that nine users in ten want
/// them, and the tenth needs a way out. **Without this the truly bare
/// project - `src/` and `default.project.json`, nothing else - stops being
/// reachable**, which is the property the whole artifact model exists for.
///
/// Not a prompt everyone answers: one keystroke past it for the nine, one
/// extra screen for the tenth.
fn customize_plan(full_plan: &[artifacts::Planned], dropped: &[String]) -> Result<Vec<String>> {
    crate::diagnostics::event("prompt", "Files to keep");
    let droppable: Vec<&artifacts::Planned> = full_plan
        .iter()
        .filter(|p| artifacts::find(p.key).is_some_and(artifacts::Artifact::droppable))
        .collect();
    if droppable.is_empty() {
        return Ok(Vec::new());
    }

    let options: Vec<String> = droppable
        .iter()
        .map(|p| ui::option_line(p.key, &p.reason.describe(), "keep"))
        .collect();
    // Pre-checked to the current state rather than to "all", so re-entering
    // shows what you already decided instead of silently undoing it.
    let keep_now: Vec<usize> = droppable
        .iter()
        .enumerate()
        .filter(|(_, p)| !dropped.iter().any(|d| d == p.key))
        .map(|(i, _)| i)
        .collect();

    let kept = MultiSelect::new("Files to keep", options)
        .with_default(&keep_now)
        .with_help_message(ui::MULTISELECT_HELP)
        .with_formatter(&ui::compact_multi_answer)
        .prompt()?;

    Ok(droppable
        .iter()
        .filter(|p| !kept.iter().any(|k| ui::option_is(k, p.key)))
        .map(|p| p.key.to_string())
        .collect())
}

/// The summary as text. Pure, so the wording is a test rather than something
/// only visible by running the whole flow.
pub(super) fn summary_lines(
    name: &str,
    graph: &ProjectGraph,
    planned: &[artifacts::Planned],
) -> Vec<String> {
    let packages = &graph.package_set();
    let chosen = &graph.choices();
    let mut lines = vec![format!("  {name}"), String::new()];

    let strategy = match graph.package_workflow {
        PackageWorkflow::Wally => "Wally",
        PackageWorkflow::GitSubmodules => "git submodules",
        PackageWorkflow::None => "none",
    };
    lines.push(format!("  {:<14}{strategy}", "Dependencies"));
    lines.push(format!(
        "  {:<14}{}",
        "Packages",
        if packages.is_empty() {
            "none".to_string()
        } else {
            packages.iter().cloned().collect::<Vec<_>>().join(", ")
        }
    ));

    // Capability, then the thing that provides it - the user should never
    // have to guess what "linting" actually installed.
    let does: Vec<String> = chosen
        .iter()
        .filter_map(|(key, implementation)| {
            let capability = capabilities::find(key)?;
            let implementation = implementation
                .as_deref()
                .and_then(|i| capability.implementation(i))
                .unwrap_or_else(|| capability.default_implementation());
            Some(format!("{key} ({})", implementation.display))
        })
        .collect();
    lines.push(format!(
        "  {:<14}{}",
        "Does",
        if does.is_empty() {
            "nothing extra".to_string()
        } else {
            does.join(", ")
        }
    ));
    if graph.test_runner() == Some(TestRunner::JestRoblox) {
        lines.push(format!("  {:<14}{}", "Jest execution", match graph.jest_backend() {
            crate::graph::JestBackend::Studio => "Local Studio; test before pushing. CI has no test step.",
            crate::graph::JestBackend::OpenCloud => "Open Cloud; CI runs tests. Configure the Roblox API key, universe ID and dedicated test place ID.",
        }));
    }

    let artifact_keys: Vec<&str> = planned.iter().map(|entry| entry.key).collect();
    let configurable: Vec<_> = tool_settings::CONFIGURABLE_TOOLS
        .iter()
        .filter(|tool| tool.applies_to(&artifact_keys))
        .collect();
    if !configurable.is_empty() {
        lines.push(String::new());
        lines.push("  Configure after creation".to_string());
        let width = configurable
            .iter()
            .map(|tool| tool.key.len())
            .max()
            .unwrap_or(0);
        for tool in configurable {
            lines.push(format!(
                "    rproj configure {:<width$}  {}",
                tool.key, tool.display_name
            ));
        }
    }

    lines.push(String::new());
    lines.push("  Creates".to_string());
    let width = planned.iter().map(|p| p.key.len()).max().unwrap_or(0);
    for entry in planned {
        lines.push(format!(
            "    {:<width$}  {}",
            entry.key,
            entry.reason.describe()
        ));
    }
    lines
}

/// The strategy the project ends up with, after checking the chosen packages
/// against it.
///
/// Some packages (the react-lua family) only ship a working module through an
/// npm/pnpm install step upstream, so raw git submodules cannot vendor them
/// at all. Checked over the **transitive closure**, not just what was picked:
/// `reactReflex` is vendorable itself and reaches for React, which is not, and
/// before this it scaffolded happily and failed at runtime in Studio with no
/// build error anywhere.
///
/// This used to run *before* the strategy was chosen and silently force
/// Wally. Now the user has already said what they want, so the conflict is
/// put to them as a **forward correction** - the same fact, offered as a
/// revision of a decision they made knowingly rather than an override
/// announced after the fact.
fn reconcile_strategy(
    workflow: PackageWorkflow,
    packages: BTreeSet<String>,
) -> Result<(PackageWorkflow, BTreeSet<String>)> {
    if workflow != PackageWorkflow::GitSubmodules || packages.is_empty() {
        return Ok((workflow, packages));
    }
    let blocked = wally_packages::unvendorable_in_closure(&packages);
    if blocked.is_empty() {
        return Ok((workflow, packages));
    }

    for (key, pulled_in_by) in &blocked {
        match pulled_in_by {
            // Names the dependent, not just the blocker: someone who never
            // picked react has no way to connect the two otherwise.
            Some(dependent) => ui::warn(&format!(
                "{dependent} requires {key}, which upstream only ships through an npm install \
                 step - git submodules can't reproduce that"
            )),
            None => ui::warn(&format!(
                "{key} only ships through an npm install step upstream, which git submodules \
                 can't reproduce"
            )),
        }
    }

    let choice = Select::new(
        "So this selection can't be vendored. What now?",
        owned(&[
            "wally - switch this project to Wally, which handles every catalog package",
            "drop - keep git submodules and remove the packages that can't be vendored",
        ]),
    )
    .with_formatter(&ui::compact_select_answer)
    .prompt()?;

    if ui::option_key(&choice) != "drop" {
        return Ok((PackageWorkflow::Wally, packages));
    }
    // Keep submodules, and actually remove what cannot be vendored - both
    // the blockers and anything that reaches them, or the project scaffolds
    // around a package whose dependency is missing.
    let removed: Vec<String> = blocked
        .iter()
        .flat_map(|(key, via)| [Some((*key).to_string()), via.map(|d| d.to_string())])
        .flatten()
        .collect();
    let kept: BTreeSet<String> = packages
        .iter()
        .filter(|k| !removed.contains(k))
        .cloned()
        .collect();
    ui::skip(&format!("removed: {}", {
        let mut names: Vec<&str> = removed.iter().map(String::as_str).collect();
        names.sort_unstable();
        names.dedup();
        names.join(", ")
    }));
    Ok((PackageWorkflow::GitSubmodules, kept))
}

fn pick_guided(workflow: PackageWorkflow) -> Result<BTreeSet<String>> {
    let mut packages = BTreeSet::new();

    for category in Category::ALL {
        let choices: Vec<&PackageSpec> = wally_packages::in_category(category)
            .filter(|p| p.primary_choice && offerable_package(p, workflow))
            .collect();
        // Testing empties out here, because a test runner is the
        // implementation of a capability rather than a package the user
        // picks. A category with nothing offerable is skipped rather than
        // shown as a list containing only "none".
        if choices.is_empty() {
            continue;
        }

        let options: Vec<String> = choices
            .iter()
            .map(|p| ui::option_line(p.key, p.description, p.maintenance.short_badge()))
            .collect();
        // Just the category name. The question is implied by a picker, and
        // the answer line reads as `State management: reflex` rather than
        // `State management: which do you want? reflex`.
        let prompt = format!("{}:", category.label());
        crate::diagnostics::event("prompt", &prompt);

        if category.allows_multiple() {
            let selected = MultiSelect::new(&prompt, options)
                .with_help_message(ui::MULTISELECT_HELP)
                .with_formatter(&ui::compact_multi_answer)
                .prompt()?;
            for spec in &choices {
                if selected.iter().any(|s| ui::option_is(s, spec.key)) {
                    packages.insert(spec.key.to_string());
                }
            }
        } else {
            // `none` **first**, because `Select` highlights index 0 and the
            // safe answer must be the resting position. It used to be
            // appended last, so pressing enter through four categories
            // handed a beginner four packages they never chose.
            let mut options = options;
            options.insert(0, "none".to_string());
            let picked = Select::new(&prompt, options)
                .with_formatter(&ui::compact_select_answer)
                .prompt()?;
            if picked != "none"
                // Matched on the full "key - " prefix, not a bare
                // starts_with: two keys where one prefixes the other
                // (react/reactRoblox, charm/charmSync) would otherwise
                // resolve to whichever the catalog happened to list first.
                && let Some(spec) = choices.iter().find(|p| ui::option_is(&picked, p.key))
            {
                packages.insert(spec.key.to_string());
            }
        }
    }

    add_companions(&mut packages);
    Ok(packages)
}

fn pick_expert(workflow: PackageWorkflow) -> Result<BTreeSet<String>> {
    // Same filter as the guided path. Without it the flat list would still
    // offer TestEZ, letting someone take the package with none of the test
    // layout that makes it useful - the double-ask, reintroduced through the
    // back door.
    let offerable: Vec<&PackageSpec> = wally_packages::PACKAGES
        .iter()
        .filter(|p| offerable_package(p, workflow))
        .collect();

    let options: Vec<String> = offerable
        .iter()
        .map(|p| {
            format!(
                "{} - {} [{}] ({})",
                p.key,
                p.description,
                p.category.label(),
                p.maintenance.short_badge()
            )
        })
        .collect();

    let selected = MultiSelect::new("Pick every package this project needs", options)
        .with_help_message(ui::MULTISELECT_HELP)
        .with_formatter(&ui::compact_multi_answer)
        .prompt()?;

    Ok(offerable
        .iter()
        .filter(|p| selected.iter().any(|s| ui::option_is(s, p.key)))
        .map(|p| p.key.to_string())
        .collect())
}

pub(super) fn add_companions(packages: &mut BTreeSet<String>) {
    let primaries: Vec<String> = packages.iter().cloned().collect();
    let snapshot = packages.clone();
    for key in primaries {
        for companion in companions_for(&key, |k| snapshot.contains(k)) {
            packages.insert(companion.to_string());
        }
    }
}

struct ScaffoldOptions<'a> {
    packages: &'a BTreeSet<String>,
    package_workflow: PackageWorkflow,
    project_tools: &'a [String],
    test_runner: Option<TestRunner>,
    jest_backend: crate::graph::JestBackend,
    chosen_artifacts: &'a [String],
    project_template: Option<&'a serde_json::Value>,
}

fn scaffold(project_dir: &Path, name: &str, options: ScaffoldOptions<'_>) -> Result<()> {
    let ScaffoldOptions {
        packages,
        package_workflow,
        project_tools,
        test_runner,
        jest_backend,
        chosen_artifacts,
        project_template,
    } = options;
    // Which files this project gets, resolved from the selections rather
    // than decided by the order of the calls below. Everything after this
    // asks `writes(...)` instead of inventing its own condition - which is
    // how six artifacts came to be written whatever the user answered.
    // Already resolved by `pick_artifacts`, so this is a membership test
    // rather than a second resolution that could disagree with the first.
    let writes = |key: &str| chosen_artifacts.iter().any(|k| k == key);

    git::ensure_repo_init(project_dir)?;

    // Both calls sit behind the same gate. `rokit add` writes rokit.toml
    // itself, so pinning while the file was declined would have re-created it
    // anyway - the answer could not have been honoured, which is why the
    // artifact is entailed by having any tool to pin.
    //
    // Ungated, this was worse than untidy: on a machine with nine tools
    // selected and a project whose rokit.toml had been declined, `rokit add`
    // ran nine times against a directory with no manifest, rokit walked up to
    // the global one, and the project pinned **nothing** - silently, because
    // each failure is warned and continued. Verified on a real scaffold:
    // `t1/` contained no rokit.toml at all.
    if writes("rokit.toml") {
        toolchain::ensure_rokit_init(project_dir)?;
        toolchain::add_selected_tools(project_dir, project_tools)?;
    }
    if writes("selene.toml") {
        toolchain::ensure_selene_config(
            project_dir,
            packages.contains("testez"),
            package_workflow,
            wally_packages::allows_mixed_tables(packages),
        )?;
    }
    if writes("stylua.toml") {
        toolchain::ensure_stylua_config(project_dir)?;
    }

    let testez_selected = test_runner == Some(TestRunner::TestEz);
    // Only the Wally workflow has realms at all - a submodule checkout is
    // just files, mounted wholesale under modules/ regardless.
    let has_server_packages =
        package_workflow == PackageWorkflow::Wally && wally_packages::has_server_realm(packages);
    rojo::scaffold_project_json(
        project_dir,
        name,
        package_workflow,
        testez_selected,
        has_server_packages,
        project_template,
    )?;
    if writes("tests") {
        let examples = writes("test-examples");
        match test_runner {
            Some(TestRunner::TestEz) => {
                testez::ensure_test_tree(project_dir, examples)?;
                testez::ensure_tests_luaurc(project_dir)?;
            }
            Some(TestRunner::JestRoblox) => jest::ensure_test_tree(project_dir, examples)?,
            None => {}
        }
    }
    if writes("jest.project.json") {
        jest::refresh_project(project_dir)?;
    }
    if writes("jest.config.json") {
        jest::ensure_config(project_dir, jest_backend)?;
    }

    // default.project.json maps a $path (packages/ or modules/) that has to
    // exist before rojo will touch it at all - generating a sourcemap while
    // that folder is missing fails outright ("could not be turned into a
    // Roblox Instance"), not just incompletely. So the package install has
    // to happen, and the folder has to exist, before sourcemap generation -
    // which is why each workflow generates its own sourcemap at the end of
    // its own branch rather than sharing one call afterwards.
    match package_workflow {
        // The `writes` checks here look redundant against the `match` -
        // the artifacts require these exact workflows. They are what makes
        // the requirement live in one place rather than two, and what lets
        // a test assert every offered artifact has a gate.
        PackageWorkflow::Wally if writes("wally.toml") => {
            let package_name = format!("rproj/{}", slugify(name));
            let package_list: Vec<String> = packages.iter().cloned().collect();
            wally::ensure_wally_init(project_dir)?;
            wally::write_wally_toml(project_dir, &package_name, &package_list)?;
            // Installs, generates the sourcemap, and re-adds the exported
            // types a plain `wally install` leaves off. See `wally::sync`.
            let sourcemap_project = if test_runner == Some(TestRunner::JestRoblox) {
                jest::PROJECT_FILE
            } else {
                "default.project.json"
            };
            wally::sync_for_project(project_dir, sourcemap_project)?;
        }
        PackageWorkflow::GitSubmodules if writes("modules") => {
            // Dedupe by target directory, not by package: monorepos like
            // littensy/charm back several catalog entries (charm,
            // charmSync, videCharm) from one clone.
            let mut cloned = BTreeSet::new();
            for spec in modules::vendorable(packages) {
                let sub = spec.submodule.expect("vendorable() filtered to Some");
                if cloned.insert(sub.dir) {
                    git::add_submodule(project_dir, spec.git_repo, sub.dir)?;
                }
            }
            // Both of these have to exist before the sourcemap runs below:
            // the nested project file is what stops Rojo from walking into
            // the vendored repos' own project files, and the link files are
            // what project code actually requires.
            modules::write_submodules_project(project_dir, packages)?;
            modules::write_link_files(project_dir, packages)?;
            // Now that modules/ exists, an initial sourcemap.json for
            // luau-lsp. (The Wally branch got its own inside `wally::sync`,
            // which needs it for wally-package-types.)
            if writes("sourcemap.json") {
                rojo::generate_sourcemap(project_dir)?;
            }
        }
        // Declined the dependency manifest, so there is nothing to install.
        // A project can legitimately want the tree and manage packages by
        // hand.
        _ => {
            if writes("sourcemap.json") {
                rojo::generate_sourcemap(project_dir)?;
            }
        }
    }

    // Quality gate. The check script is generated from the tools this
    // project actually selected, so it never invokes something that was
    // never installed; CI only lands if there's a script for it to run.
    // selene.toml says std = "roblox+testez", and without testez.yml to
    // resolve it selene prints "Could not find all standard library files"
    // and lints nothing at all - src/ included. Hence entailed by selene
    // being pinned rather than offered.
    if writes("testez.yml") {
        testez::ensure_selene_std(project_dir)?;
    }
    if writes("testez-companion.toml") {
        testez::ensure_companion_config(project_dir)?;
    }

    // Tells the editor which folders are vendored third-party code. Only
    // needed for the submodule workflow - luau-lsp already ignores Wally's
    // `_Index` by default, which is why that workflow never showed this.
    if writes(".vscode/settings.json") {
        vscode::ensure_project_settings(project_dir, package_workflow)?;
    }

    if writes(".luaurc") {
        quality::ensure_luaurc(project_dir)?;
    }
    // The same list the project's rokit.toml got: a check script must only
    // invoke tools this project actually pins, or CI fails on a command that
    // isn't installed.
    if writes(".lute/check.luau")
        && quality::ensure_check_script(project_dir, project_tools, test_runner.is_some())?
    {
        // The workflow only exists to run the script, so the artifact model
        // requires one on the other - unticking the script drops the CI
        // file rather than leaving a workflow whose first command is
        // missing.
        if writes(".github/workflows/ci.yml") {
            quality::ensure_ci_workflow(
                project_dir,
                package_workflow,
                has_server_packages,
                test_runner,
                jest_backend,
            )?;
        }
        quality::lute_setup(project_dir)?;
    }

    if writes(".gitignore") {
        gitignore::ensure_entries(project_dir)?;
    }
    if writes(".gitattributes") {
        gitattributes::ensure_gitattributes(project_dir)?;
    }

    // Was `config.blender_enabled()`, which meant that selecting Blender once at
    // setup silently added a folder to every project afterwards. Now it is
    // an artifact requiring the app AND defaulting to off, so it is opt-in
    // per project.
    if writes("blender") {
        blender::scaffold_starter_scene(project_dir)?;
    }
    if writes("figma") {
        figma::scaffold_design_folder(project_dir)?;
    }
    // After figma/, so each asset pipeline can see the exports folder and
    // point at it rather than at a generic assets/ directory.
    if writes("asphalt.toml") {
        asphalt::ensure_config(project_dir)?;
    }
    if writes("tungsten.toml") {
        tungsten::ensure_config(project_dir)?;
    }

    Ok(())
}

fn slugify(name: &str) -> String {
    name.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() {
                c.to_ascii_lowercase()
            } else {
                '-'
            }
        })
        .collect()
}

/// Resolves `--like`, failing with the list of what *is* available rather
/// than a bare "not found" - the names are user-chosen, so a typo is the
/// likely cause and the correction is right there.
///
/// Also re-applies the workflow guard that `pick_package_workflow` would
/// have applied interactively. A saved setup records an answer given at
/// some earlier point; a package can stop being vendorable since (or the
/// file can be edited by hand), and scaffolding a submodule project around
/// a package that has no vendorable source produces a broken tree.
fn load_setup(name: &str) -> Result<(String, ProjectGraph)> {
    let (setup, warnings) = read_setup(name)?;
    for warning in warnings {
        ui::warn(&warning);
    }
    Ok((name.to_string(), setup))
}

pub(super) fn read_setup(name: &str) -> Result<(ProjectGraph, Vec<String>)> {
    let mut warnings = Vec::new();
    if let Some(mut setup) = Setups::load(name)? {
        if !setup.testing_is_compatible() {
            bail!(
                "saved setup `{name}` selects Jest Roblox without Wally; edit or replace the setup before using it"
            );
        }
        // Same transitive check as the interactive path: a saved setup can
        // name only vendorable packages and still be unbuildable because one
        // of them requires something that isn't.
        let selected: BTreeSet<String> = setup.packages.iter().cloned().collect();
        let blocked = wally_packages::unvendorable_in_closure(&selected);
        if setup.package_workflow == PackageWorkflow::GitSubmodules && !blocked.is_empty() {
            let reasons: Vec<String> = blocked
                .iter()
                .map(|(key, via)| match via {
                    Some(dependent) => format!("{key} (required by {dependent})"),
                    None => (*key).to_string(),
                })
                .collect();
            warnings.push(format!(
                "setup `{name}` asks for git submodules, but {} can't be vendored that way - using Wally",
                reasons.join(", ")
            ));
            setup.package_workflow = PackageWorkflow::Wally;
        }

        let unknown: Vec<&str> = setup
            .packages
            .iter()
            .filter(|k| wally_packages::find(k).is_none())
            .map(String::as_str)
            .collect();
        if !unknown.is_empty() {
            warnings.push(format!(
                "setup `{name}` names packages no longer in the catalog, skipping them: {}",
                unknown.join(", ")
            ));
            setup.packages.retain(|k| wally_packages::find(k).is_some());
        }

        return Ok((setup, warnings));
    }
    let available = Setups::list();
    if available.is_empty() {
        bail!(
            "no saved setup called `{name}` - none have been saved yet. \
             Add `--save-setup <name>` to a `rproj new` run to create one."
        );
    }
    bail!(
        "no saved setup called `{name}`. Available: {}",
        available.join(", ")
    )
}

#[cfg(test)]
mod tests {
    #[test]
    fn capability_prompt_driver() {
        let Ok(workflow) = std::env::var("RPROJ_CAPABILITY_PROMPT_WORKFLOW") else {
            return;
        };
        let workflow = serde_json::from_str(&format!("\"{workflow}\"")).unwrap();
        let choices = super::pick_capabilities(workflow).unwrap();
        let mut graph = ProjectGraph::default();
        for (key, implementation) in choices {
            graph.choose(&key, implementation.as_deref());
        }
        println!(
            "CHOICES {}",
            serde_json::to_string(&graph.capabilities).unwrap()
        );
    }

    #[test]
    fn direct_capability_prompts_keep_runner_choices_and_skip_missing_requirements() {
        use crate::test_common::{DOWN, ENTER, Session};

        for (workflow, capability, backend, expected) in [
            ("wally", "test", "local", "jest-roblox"),
            ("wally", "test", "cloud", "jest-roblox-open-cloud"),
            ("git-submodules", "test", "", "testez"),
            ("none", "test", "", "testez"),
            ("wally", "ci", "", ""),
        ] {
            let root = tempfile::tempdir().unwrap();
            let mut session = Session::start_program(
                &std::env::current_exe().unwrap(),
                root.path(),
                &[
                    "commands::new::tests::capability_prompt_driver",
                    "--exact",
                    "--nocapture",
                ],
                &[("RPROJ_CAPABILITY_PROMPT_WORKFLOW", workflow)],
            );
            session.wait_for("What should this project do?");
            let index = capabilities::CAPABILITIES
                .iter()
                .position(|c| c.key == capability)
                .unwrap();
            for _ in 0..index {
                session.send(DOWN);
            }
            session.send(" ");
            session.send(ENTER);
            if !backend.is_empty() {
                session.wait_for("test:");
                session.send(ENTER);
                session.wait_for("Jest execution:");
                if backend == "cloud" {
                    session.send(DOWN);
                }
                session.send(ENTER);
            }
            let result = session.finish();
            assert_eq!(result.code, 0, "{}", result.text);
            if capability == "ci" {
                assert!(
                    result.text.contains("ci needs gate - skipping it"),
                    "{}",
                    result.text
                );
                assert!(result.text.contains("CHOICES {}"), "{}", result.text);
            } else {
                assert!(
                    result
                        .text
                        .contains(&format!("CHOICES {{\"test\":\"{expected}\"}}")),
                    "{}",
                    result.text
                );
            }
            assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
        }
    }

    #[test]
    fn creating_a_project_never_reuses_an_existing_directory() {
        let root = tempfile::tempdir().unwrap();
        let path = root.path().join("existing");
        std::fs::create_dir(&path).unwrap();
        std::fs::write(path.join("user.txt"), "preserve me").unwrap();
        assert!(super::create_project_dir(&path).is_err());
        let mut config = GlobalConfig::default();
        let before = serde_json::to_value(&config).unwrap();
        let mut graph = ProjectGraph {
            package_workflow: PackageWorkflow::Wally,
            ..Default::default()
        };
        graph.choose("test", Some("jest-roblox"));
        let planned = graph.plan(&[], &[]);
        assert!(
            execute_confirmed("existing", &path, &graph, &planned, None, &mut config, None,)
                .is_err()
        );
        assert_eq!(serde_json::to_value(&config).unwrap(), before);
        assert_eq!(std::fs::read_dir(&path).unwrap().count(), 1);
        assert_eq!(
            std::fs::read_to_string(path.join("user.txt")).unwrap(),
            "preserve me"
        );
    }

    #[test]
    fn creating_a_project_creates_missing_parents() {
        let root = tempfile::tempdir().unwrap();
        let path = root.path().join("projects/new");
        super::create_project_dir(&path).unwrap();
        assert!(path.is_dir());
    }
    use super::*;

    /// **Every package the picker offers is one the strategy can install.**
    /// Under submodules that excludes the react family, which upstream ships
    /// only through an npm step - offering them would be offering a project
    /// that breaks at runtime in Studio with no build error anywhere.
    #[test]
    fn the_package_picker_never_offers_what_the_strategy_cannot_install() {
        for spec in wally_packages::PACKAGES {
            if offerable_package(spec, PackageWorkflow::GitSubmodules) {
                assert!(
                    spec.submodule.is_some(),
                    "{} has no vendorable source but is offered under submodules",
                    spec.key
                );
            }
            // Wally installs everything, so only capability-owned entries
            // are held back there.
            assert_eq!(
                offerable_package(spec, PackageWorkflow::Wally),
                !capability_owned(spec.key),
                "{}",
                spec.key
            );
        }
    }

    /// TestEZ left the package picker: *do I want tests* is a capability
    /// question, and asking it again as a package was one intent asked
    /// twice, four prompts apart.
    #[test]
    fn the_test_runner_is_not_offered_as_a_package() {
        assert!(
            capability_owned("testez"),
            "the test capability must own it"
        );
        let testez = wally_packages::find("testez").expect("in the catalog");
        for workflow in [PackageWorkflow::Wally, PackageWorkflow::GitSubmodules] {
            assert!(!offerable_package(testez, workflow));
        }
    }

    /// A machine that has never been provisioned must still get the
    /// questions; one that has must not be asked again.
    #[test]
    fn machine_is_only_configured_once() {
        let fresh = GlobalConfig::default();
        assert!(
            !fresh.machine_configured(),
            "a fresh config needs provisioning"
        );

        let provisioned = GlobalConfig {
            last_checked: Some("123".into()),
            ..Default::default()
        };
        assert!(
            provisioned.machine_configured(),
            "already provisioned, don't re-ask"
        );
    }

    /// The skip path prints this instead of the pickers, so it has to say
    /// something rather than being blank on a real config.
    #[test]
    fn machine_summary_describes_what_is_set_up() {
        let config = GlobalConfig {
            selected_system_apps: vec!["git".into(), "vscode".into()],
            selected_rokit_tools: vec!["rojo".into()],
            ..Default::default()
        };
        let summary = config.machine_summary();
        assert!(summary.contains("2 apps"), "{summary}");
        assert!(summary.contains("1 tools"), "{summary}");
        assert!(
            !summary.contains("plugins"),
            "empty groups shouldn't be listed: {summary}"
        );
        assert_eq!(
            GlobalConfig::default().machine_summary(),
            "nothing selected"
        );
    }

    /// A graph built the way `run` builds one, so the tests exercise the
    /// same path the command does.
    fn graph_of(workflow: PackageWorkflow, packages: &[&str], caps: &[&str]) -> ProjectGraph {
        let mut graph = ProjectGraph {
            package_workflow: workflow,
            packages: packages.iter().map(|p| p.to_string()).collect(),
            ..Default::default()
        };
        for key in caps {
            graph.choose(key, None);
        }
        graph
    }

    /// **"Just the Rojo basics."** No dependency manager, nothing chosen -
    /// and the result is the source tree, the project file, and the two
    /// housekeeping entries. Nothing about the machine can change this.
    #[test]
    fn choosing_nothing_yields_only_the_basics() {
        let graph = graph_of(PackageWorkflow::None, &[], &[]);
        let keys: Vec<&str> = graph.plan(&[], &[]).iter().map(|p| p.key).collect();
        assert_eq!(
            keys,
            ["src", "default.project.json", "rproj.toml", ".gitignore"]
        );
    }

    /// Choosing a capability derives its tool *and* its file, in one answer.
    /// This is the pair the user used to have to state twice.
    #[test]
    fn one_capability_derives_both_the_tool_and_the_file() {
        let graph = graph_of(PackageWorkflow::None, &[], &["lint"]);
        assert_eq!(graph.tools(), ["selene"]);
        let keys: Vec<&str> = graph.plan(&[], &[]).iter().map(|p| p.key).collect();
        assert!(keys.contains(&"selene.toml"), "{keys:?}");
    }

    /// The summary is the user-visible half, so its exact text is a test.
    /// Every file names the answer that caused it - that is what makes it an
    /// explanation rather than a receipt.
    #[test]
    fn the_summary_names_every_file_and_why_it_is_there() {
        let graph = graph_of(PackageWorkflow::Wally, &["reflex"], &["lint", "format"]);
        let planned = graph.plan(&[], &[]);
        let text = summary_lines("MyGame", &graph, &planned).join(
            "
",
        );

        assert!(text.contains("  MyGame"), "{text}");
        assert!(text.contains("Dependencies  Wally"), "{text}");
        assert!(text.contains("Packages      reflex"), "{text}");
        // The tool is named beside the capability, never hidden behind it.
        assert!(text.contains("lint (Selene)"), "{text}");
        assert!(text.contains("format (StyLua)"), "{text}");
        assert!(text.contains("rproj configure selene"), "{text}");
        assert!(text.contains("rproj configure stylua"), "{text}");
        assert!(!text.contains("rproj configure luau-lsp"), "{text}");
        assert!(!text.contains("rproj configure stylua-vscode"), "{text}");
        assert!(text.contains("selene.toml"), "{text}");
        assert!(text.contains("you chose lint"), "{text}");
        assert!(text.contains("wally.toml"), "{text}");
        assert!(text.contains("this project uses Wally"), "{text}");
    }

    /// A project with no capabilities still reads sensibly rather than
    /// showing an empty column.
    #[test]
    fn the_summary_says_so_when_nothing_extra_was_chosen() {
        let graph = graph_of(PackageWorkflow::None, &[], &[]);
        let planned = graph.plan(&[], &[]);
        let text = summary_lines("bare", &graph, &planned).join(
            "
",
        );
        assert!(text.contains("Dependencies  none"), "{text}");
        assert!(text.contains("Packages      none"), "{text}");
        assert!(text.contains("Does          nothing extra"), "{text}");
        assert!(!text.contains("Configure after creation"), "{text}");
    }

    #[test]
    fn editor_summary_offers_only_relevant_editor_configuration() {
        let graph = graph_of(PackageWorkflow::None, &[], &["editor"]);
        let planned = graph.plan(&["vscode".to_string()], &[]);
        let text = summary_lines("editor", &graph, &planned).join("\n");

        assert!(text.contains("rproj configure luau-lsp"), "{text}");
        assert!(!text.contains("rproj configure stylua-vscode"), "{text}");

        let formatted = graph_of(PackageWorkflow::None, &[], &["format", "editor"]);
        let planned = formatted.plan(&["vscode".to_string()], &[]);
        let text = summary_lines("formatted-editor", &formatted, &planned).join("\n");
        assert!(text.contains("rproj configure stylua-vscode"), "{text}");
    }

    /// The revision menu shows what each answer currently *is*, so "change
    /// which answer?" is answerable without remembering what was said.
    #[test]
    fn the_revision_menu_describes_the_current_answers() {
        let graph = graph_of(PackageWorkflow::GitSubmodules, &["charm"], &["lint"]);
        assert_eq!(describe_node(&graph, Node::Strategy), "git submodules");
        assert_eq!(describe_node(&graph, Node::Packages), "charm");
        assert_eq!(describe_node(&graph, Node::Capabilities), "lint");

        let empty = ProjectGraph::default();
        assert_eq!(describe_node(&empty, Node::Packages), "none");
        assert_eq!(describe_node(&empty, Node::Capabilities), "nothing extra");
    }
}