pleme-doc-gen 0.1.53

Rust replacement for the M0 Python _gen-patterns.py + _gen-docs.py scripts in pleme-io/actions. Walks every action.yml + emits substrate's patterns-full.nix + per-action README.md + root catalog. Per the NO-SHELL prime directive.
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
//! Green-CI-by-default starter emission — typed per-ecosystem trait.
//!
//! Mirrors the Validator + ReverseExtractor + ast::Render trait
//! pattern. Each ecosystem that ships a passing smoke test on
//! day-0 implements GreenCiStarter; one match arm in starter_for()
//! dispatches by ecosystem keyword.
//!
//! The compounding: adding green-CI support to a new ecosystem is
//! ONE new struct + ONE new match arm + ONE matrix row, exactly
//! parallel to how new ecosystems plug into reverse extraction +
//! validation. Per the ★★ CLOSED-LOOP MASS-SYNTHESIS directive
//! Rule 1: matrix-as-forcing-function applies to the green-CI
//! invariant the same way it applies to forge-correctness.

use anyhow::{anyhow, Result};
use std::fs;
use std::path::{Path, PathBuf};

/// Typed view over per-ecosystem source-file scaffolding. Each
/// ecosystem that supports green-CI-by-default implements this trait.
pub trait GreenCiStarter: Sync {
    /// Write the starter source files into `out_root` for a package
    /// named `name`. Returns the list of files written (or that
    /// already existed when `force == false`).
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>>;
}

// ─── Helpers ───────────────────────────────────────────────────

fn write_if_needed(path: &Path, body: &str, force: bool) -> Result<Option<PathBuf>> {
    if path.is_file() && !force { return Ok(None); }
    // Operator-safe overwrite: even with force=true, preserve files
    // that don't carry the substrate-stub marker. Substrate stubs all
    // contain the typed marker string; operator-edited source loses it.
    // This is the typed idempotence contract — re-renders never destroy
    // operator-written code, only re-emit substrate-owned stubs.
    if path.is_file() && force && !file_is_substrate_stub(path) {
        return Ok(None);
    }
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| anyhow!("create_dir_all {}: {e}", parent.display()))?;
    }
    fs::write(path, body)
        .map_err(|e| anyhow!("write {}: {e}", path.display()))?;
    Ok(Some(path.to_path_buf()))
}

/// Substrate-owned-file detector. Every starter stub contains the
/// canonical marker "Replace this stub" in a comment. Operator-edited
/// files lose that marker; write_if_needed preserves them on
/// re-render. Stable across ecosystems (every starter emits the
/// marker in its scaffolded source file).
pub fn file_is_substrate_stub(path: &Path) -> bool {
    let Ok(text) = std::fs::read_to_string(path) else { return true; };
    text.contains("Replace this stub")
}

// ─── Per-ecosystem starter impls ───────────────────────────────

pub struct RustStarter;
impl GreenCiStarter for RustStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let body = "//! pleme-io typed wrapper — auto-generated by caixa-forge.\n\
                    //!\n\
                    //! Replace this stub with the actual library implementation.\n\
                    //! The smoke test below ensures `cargo test` is green by default.\n\
                    \n\
                    #[cfg(test)]\n\
                    mod tests {\n\
                    \x20   #[test]\n\
                    \x20   fn smoke() {\n\
                    \x20       assert_eq!(2 + 2, 4);\n\
                    \x20   }\n\
                    }\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(&out_root.join("src/lib.rs"), body, force)? {
            out.push(p);
        }
        Ok(out)
    }
}

pub struct GoStarter;
impl GreenCiStarter for GoStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let main = "// pleme-io typed wrapper — auto-generated by caixa-forge.\n\
                    //\n\
                    // Replace this stub with the actual program implementation.\n\
                    package main\n\
                    \n\
                    import \"fmt\"\n\
                    \n\
                    func main() {\n\
                    \tfmt.Println(\"pleme-io wrapper stub\")\n\
                    }\n";
        let test = "package main\n\
                    \n\
                    import \"testing\"\n\
                    \n\
                    func TestSmoke(t *testing.T) {\n\
                    \tif 2+2 != 4 {\n\
                    \t\tt.Fatal(\"arithmetic broken\")\n\
                    \t}\n\
                    }\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(&out_root.join("main.go"), main, force)? {
            out.push(p);
        }
        if let Some(p) = write_if_needed(&out_root.join("main_test.go"), test, force)? {
            out.push(p);
        }
        Ok(out)
    }
}

pub struct NpmStarter;
impl GreenCiStarter for NpmStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let index = "// pleme-io typed wrapper — auto-generated by caixa-forge.\n\
                     // Replace this stub with the actual package implementation.\n\
                     \n\
                     module.exports = {\n\
                     \x20 smoke: () => 4,\n\
                     };\n";
        let test = "const { test } = require('node:test');\n\
                    const assert = require('node:assert');\n\
                    const pkg = require('../src/index.js');\n\
                    \n\
                    test('smoke', () => {\n\
                    \x20 assert.strictEqual(pkg.smoke(), 4);\n\
                    });\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(&out_root.join("src/index.js"), index, force)? {
            out.push(p);
        }
        if let Some(p) = write_if_needed(&out_root.join("test/test.js"), test, force)? {
            out.push(p);
        }
        Ok(out)
    }
}

/// Python's starter is the only one that emits via a typed AST
/// (python_ast) rather than constant string literals — the test_smoke
/// file has user-name interpolation through `Import.module`, which
/// the prime directive demands flow through a typed surface.
pub struct PythonStarter;
impl GreenCiStarter for PythonStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = name.replace('-', "_");
        let init_body = "\"\"\"pleme-io typed wrapper — auto-generated by caixa-forge.\n\
                         \n\
                         Replace this stub with the actual package implementation.\n\
                         \"\"\"\n\
                         \n\
                         def smoke() -> int:\n\
                         \x20   return 4\n";
        let init_path = out_root.join("src").join(&mod_name).join("__init__.py");
        let test_path = out_root.join("tests/test_smoke.py");

        let mut out = vec![];
        if let Some(p) = write_if_needed(&init_path, init_body, force)? {
            out.push(p);
        }

        // tests/test_smoke.py — every line through python_ast.
        if !test_path.is_file() || force {
            use crate::ast::Render;
            use crate::python_ast::{Class, Expr as PExpr, File as PFile, Stmt as PStmt};
            let mut f = PFile::new();
            f.push(PStmt::Import { module: "unittest".into(), names: vec![] });
            f.push(PStmt::Import { module: mod_name.clone(), names: vec!["smoke".into()] });

            let test_method = PStmt::FunctionDef {
                name: "test_smoke".into(),
                params: vec!["self".into()],
                body: vec![PStmt::Expr(PExpr::call("self.assertEqual", vec![
                    PExpr::pos(PExpr::call("smoke", vec![])),
                    PExpr::pos(PExpr::i(4)),
                ]))],
            };
            let mut cls = Class::new("SmokeTest", vec!["unittest.TestCase".into()]);
            cls.push(test_method);
            f.class(cls);

            f.push_after_class(PStmt::If {
                cond: r#"__name__ == "__main__""#.into(),
                body: vec![PStmt::Expr(PExpr::call("unittest.main", vec![]))],
            });

            if let Some(parent) = test_path.parent() {
                fs::create_dir_all(parent)?;
            }
            crate::ast::emit(&test_path, &f)?;
            out.push(test_path);
        }
        Ok(out)
    }
}

pub struct JavaGradleKtsStarter;
impl GreenCiStarter for JavaGradleKtsStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let main = "// pleme-io typed wrapper.\npackage io.pleme.wrapper;\npublic class Smoke { public static int value() { return 4; } }\n";
        let test = "package io.pleme.wrapper;\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nclass SmokeTest { @Test void smoke() { assertEquals(4, Smoke.value()); } }\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(&out_root.join("src/main/java/io/pleme/wrapper/Smoke.java"), main, force)? { out.push(p); }
        if let Some(p) = write_if_needed(&out_root.join("src/test/java/io/pleme/wrapper/SmokeTest.java"), test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct CppCmakeStarter;
impl GreenCiStarter for CppCmakeStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> { CppVcpkgStarter.write(out_root, _name, force) }
}

pub struct CppMesonStarter;
impl GreenCiStarter for CppMesonStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> { CppVcpkgStarter.write(out_root, _name, force) }
}

pub struct CppConanStarter;
impl GreenCiStarter for CppConanStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> { CppVcpkgStarter.write(out_root, _name, force) }
}

pub struct HaskellCabalStarter;
impl GreenCiStarter for HaskellCabalStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = pascal(&name.replace('-', "_"));
        let lib = format!("-- pleme-io typed wrapper.\nmodule {mod_name} (smoke) where\nsmoke :: Int\nsmoke = 4\n");
        let test = format!("import {mod_name}\nimport System.Exit\nmain :: IO ()\nmain = if smoke == 4 then exitSuccess else exitFailure\n");
        let mut out = vec![];
        if let Some(p) = write_if_needed(&out_root.join(format!("src/{mod_name}.hs")), &lib, force)? { out.push(p); }
        if let Some(p) = write_if_needed(&out_root.join("test/Spec.hs"), &test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct NimNimbleStarter;
impl GreenCiStarter for NimNimbleStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = name.replace('-', "_");
        let lib = format!("# pleme-io typed wrapper.\nproc smoke*(): int = 4\n");
        let test = format!("import unittest\nimport {mod_name}\n\nsuite \"smoke\":\n  test \"smoke is four\":\n    check smoke() == 4\n");
        let mut out = vec![];
        if let Some(p) = write_if_needed(&out_root.join(format!("src/{mod_name}.nim")), &lib, force)? { out.push(p); }
        if let Some(p) = write_if_needed(&out_root.join(format!("tests/test_{mod_name}.nim")), &test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct LuaRockspecStarter;
impl GreenCiStarter for LuaRockspecStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = name.replace('-', "_");
        let lib = format!("-- pleme-io typed wrapper.\nlocal M = {{}}\nfunction M.smoke() return 4 end\nreturn M\n");
        let test = format!("local m = require('{mod_name}')\nassert(m.smoke() == 4)\nprint('smoke ok')\n");
        let mut out = vec![];
        if let Some(p) = write_if_needed(&out_root.join(format!("src/{mod_name}.lua")), &lib, force)? { out.push(p); }
        if let Some(p) = write_if_needed(&out_root.join("spec/smoke_spec.lua"), &test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct RDescriptionStarter;
impl GreenCiStarter for RDescriptionStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let r = "# pleme-io typed wrapper.\nsmoke <- function() 4\n";
        let test = "library(testthat)\nsource(\"../R/smoke.R\")\ntest_that(\"smoke\", { expect_equal(smoke(), 4) })\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(&out_root.join("R/smoke.R"), r, force)? { out.push(p); }
        if let Some(p) = write_if_needed(&out_root.join("tests/testthat/test-smoke.R"), test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct PythonPipenvStarter;
impl GreenCiStarter for PythonPipenvStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> { PythonStarter.write(out_root, name, force) }
}

pub struct GithubActionStarter;
impl GreenCiStarter for GithubActionStarter {
    fn write(&self, _out_root: &Path, _name: &str, _force: bool) -> Result<Vec<PathBuf>> {
        // GH Actions wrappers are pure-config; the action.yml + run.tlisp
        // emitted by render_github_action are already runnable on the
        // GH-Actions runner. No source-starter needed.
        Ok(vec![])
    }
}

pub struct JavaMavenStarter;
impl GreenCiStarter for JavaMavenStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let main = "// pleme-io typed wrapper — auto-generated by caixa-forge.\n\
                    package io.pleme.wrapper;\n\n\
                    public final class Smoke {\n\
                    \x20   public static int value() { return 4; }\n\
                    \x20   public static void main(String[] args) { System.out.println(\"pleme-io wrapper stub\"); }\n\
                    }\n";
        let test = "package io.pleme.wrapper;\n\n\
                    import org.junit.jupiter.api.Test;\n\
                    import static org.junit.jupiter.api.Assertions.assertEquals;\n\n\
                    class SmokeTest {\n\
                    \x20   @Test void smoke() { assertEquals(4, Smoke.value()); }\n\
                    }\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join("src/main/java/io/pleme/wrapper/Smoke.java"), main, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("src/test/java/io/pleme/wrapper/SmokeTest.java"), test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct JsDenoStarter;
impl GreenCiStarter for JsDenoStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_ts = "// pleme-io typed wrapper — auto-generated by caixa-forge.\nexport function smoke(): number { return 4; }\n";
        let test_ts = "import { assertEquals } from \"jsr:@std/assert\";\nimport { smoke } from \"./mod.ts\";\n\nDeno.test(\"smoke\", () => assertEquals(smoke(), 4));\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join("mod.ts"), mod_ts, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("mod_test.ts"), test_ts, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct CppVcpkgStarter;
impl GreenCiStarter for CppVcpkgStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let main = "// pleme-io typed wrapper — auto-generated by caixa-forge.\n\
                    #include <iostream>\n\
                    \n\
                    int smoke() { return 4; }\n\
                    int main() { std::cout << \"pleme-io wrapper stub\\n\"; return 0; }\n";
        let test = "#include <cassert>\nint smoke();\nint main() { assert(smoke() == 4); return 0; }\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join("src/main.cpp"), main, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("test/test_smoke.cpp"), test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct PythonCondaStarter;
impl GreenCiStarter for PythonCondaStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        // conda recipes wrap an underlying package; the green-CI
        // starter ships the package source PythonStarter would emit,
        // since `conda build` consumes it.
        PythonStarter.write(out_root, name, force)
    }
}

pub struct AdaAlireStarter;
impl GreenCiStarter for AdaAlireStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = name.replace('-', "_");
        let lib_ads = format!(
            "-- pleme-io typed wrapper — auto-generated by caixa-forge.\n\
             package {} is\n\
             \x20  function Smoke return Integer;\n\
             end {};\n", pascal(&mod_name), pascal(&mod_name));
        let lib_adb = format!(
            "package body {} is\n\
             \x20  function Smoke return Integer is begin return 4; end Smoke;\n\
             end {};\n", pascal(&mod_name), pascal(&mod_name));
        let main_adb = format!(
            "with {}; with Ada.Text_IO;\n\
             procedure Main is\n\
             begin\n\
             \x20  Ada.Text_IO.Put_Line (Integer'Image ({}.Smoke));\n\
             end Main;\n", pascal(&mod_name), pascal(&mod_name));
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join(format!("src/{mod_name}.ads")), &lib_ads, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join(format!("src/{mod_name}.adb")), &lib_adb, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("src/main.adb"), &main_adb, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct ZigStarter;
impl GreenCiStarter for ZigStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        // src/main.zig with a builtin `test "..." { ... }` block —
        // `zig test src/main.zig` passes by default.
        let main = "//! pleme-io typed wrapper — auto-generated by caixa-forge.\n\
                    const std = @import(\"std\");\n\
                    \n\
                    pub fn smoke() i32 { return 4; }\n\
                    \n\
                    pub fn main() void {\n\
                    \x20   std.debug.print(\"pleme-io wrapper stub\\n\", .{});\n\
                    }\n\
                    \n\
                    test \"smoke\" {\n\
                    \x20   try std.testing.expectEqual(@as(i32, 4), smoke());\n\
                    }\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join("src/main.zig"), main, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct FortranFpmStarter;
impl GreenCiStarter for FortranFpmStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = name.replace('-', "_");
        let lib = format!(
            "! pleme-io typed wrapper — auto-generated by caixa-forge.\n\
             module {mod_name}\n\
             \x20 implicit none\n\
             \x20 private\n\
             \x20 public :: smoke\n\
             contains\n\
             \x20 integer function smoke()\n\
             \x20\x20\x20 smoke = 4\n\
             \x20 end function smoke\n\
             end module {mod_name}\n");
        let test = format!(
            "program test_smoke\n  use {mod_name}, only: smoke\n  implicit none\n\
             \x20 if (smoke() /= 4) error stop \"smoke broken\"\n\
             \x20 print *, \"smoke ok\"\nend program test_smoke\n");
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join(format!("src/{mod_name}.f90")), &lib, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("test/test_smoke.f90"), &test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct GleamStarter;
impl GreenCiStarter for GleamStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = name.replace('-', "_");
        let lib = format!(
            "// pleme-io typed wrapper — auto-generated by caixa-forge.\n\
             pub fn smoke() -> Int {{\n  4\n}}\n");
        let test = format!(
            "import gleeunit\nimport gleeunit/should\nimport {mod_name}\n\n\
             pub fn main() {{\n  gleeunit.main()\n}}\n\n\
             pub fn smoke_test() {{\n  {mod_name}.smoke() |> should.equal(4)\n}}\n");
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join(format!("src/{mod_name}.gleam")), &lib, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join(format!("test/{mod_name}_test.gleam")), &test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct RacketInfoStarter;
impl GreenCiStarter for RacketInfoStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = name.replace('-', "_");
        let lib = ";; pleme-io typed wrapper — auto-generated by caixa-forge.\n\
                   #lang racket\n\
                   (provide smoke)\n\
                   (define (smoke) 4)\n";
        let test = format!(
            ";; smoke test for {mod_name}\n#lang racket\n\
             (require rackunit \"{mod_name}.rkt\")\n\n\
             (check-equal? (smoke) 4)\n");
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join(format!("{mod_name}.rkt")), lib, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("tests/smoke.rkt"), &test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct CrystalStarter;
impl GreenCiStarter for CrystalStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = name.replace('-', "_");
        let src = format!(
            "# pleme-io typed wrapper — auto-generated by caixa-forge.\n\
             module {} \n\
             \x20 def self.smoke\n    4\n  end\nend\n",
            pascal(&mod_name));
        let spec = format!(
            "require \"spec\"\nrequire \"../src/{mod_name}\"\n\n\
             describe {} do\n  it \"smoke\" do\n    {}.smoke.should eq(4)\n  end\nend\n",
            pascal(&mod_name), pascal(&mod_name));
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join(format!("src/{mod_name}.cr")), &src, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("spec/smoke_spec.cr"), &spec, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct DartStarter;
impl GreenCiStarter for DartStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = name.replace('-', "_");
        let lib = format!(
            "// pleme-io typed wrapper — auto-generated by caixa-forge.\n\
             library {mod_name};\n\n\
             int smoke() => 4;\n");
        let test = format!(
            "import 'package:test/test.dart';\nimport 'package:{mod_name}/{mod_name}.dart';\n\n\
             void main() {{\n  test('smoke', () {{\n    expect(smoke(), equals(4));\n  }});\n}}\n");
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join(format!("lib/{mod_name}.dart")), &lib, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("test/smoke_test.dart"), &test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct ComposerStarter;
impl GreenCiStarter for ComposerStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let src = "<?php\n// pleme-io typed wrapper — auto-generated by caixa-forge.\n\
                   namespace Pleme\\Wrapper;\n\nclass Smoke {\n    public static function value(): int { return 4; }\n}\n";
        let test = "<?php\nuse PHPUnit\\Framework\\TestCase;\nuse Pleme\\Wrapper\\Smoke;\n\n\
                    class SmokeTest extends TestCase {\n    public function testSmoke() { $this->assertEquals(4, Smoke::value()); }\n}\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join("src/Smoke.php"), src, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("tests/SmokeTest.php"), test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct JuliaStarter;
impl GreenCiStarter for JuliaStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = pascal(&name.replace('-', "_"));
        let src = format!(
            "# pleme-io typed wrapper — auto-generated by caixa-forge.\n\
             module {mod_name}\n\nexport smoke\n\nsmoke() = 4\n\nend # module\n");
        let test = format!(
            "using Test\nusing {mod_name}\n\n\
             @testset \"smoke\" begin\n    @test {mod_name}.smoke() == 4\nend\n");
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join(format!("src/{mod_name}.jl")), &src, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("test/runtests.jl"), &test, force)? { out.push(p); }
        Ok(out)
    }
}

/// Capitalise each underscore-separated segment: `my_thing` → `MyThing`.
fn pascal(s: &str) -> String {
    s.split('_').map(|p| {
        let mut c = p.chars();
        c.next().map(|f| f.to_uppercase().chain(c).collect::<String>())
            .unwrap_or_default()
    }).collect()
}

pub struct ScalaSbtStarter;
impl GreenCiStarter for ScalaSbtStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        // Standard sbt layout: src/main/scala/Main.scala + src/test/scala/SmokeTest.scala
        let main = "// pleme-io typed wrapper — auto-generated by caixa-forge.\n\
                    object Main {\n\
                    \x20 def smoke: Int = 4\n\
                    \x20 def main(args: Array[String]): Unit = println(\"pleme-io wrapper stub\")\n\
                    }\n";
        let test = "import org.scalatest.funsuite.AnyFunSuite\n\
                    \n\
                    class SmokeTest extends AnyFunSuite {\n\
                    \x20 test(\"smoke\") { assert(Main.smoke == 4) }\n\
                    }\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join("src/main/scala/Main.scala"), main, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("src/test/scala/SmokeTest.scala"), test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct ClojureDepsStarter;
impl GreenCiStarter for ClojureDepsStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        // Standard deps.edn layout: src/<ns>/core.clj + test/<ns>/core_test.clj
        let ns = name.replace('-', "_");
        let core = format!(
            ";; pleme-io typed wrapper — auto-generated by caixa-forge.\n\
             (ns {ns}.core)\n\
             \n\
             (defn smoke [] 4)\n"
        );
        let test = format!(
            "(ns {ns}.core-test\n  (:require [clojure.test :refer :all]\n            [{ns}.core :as core]))\n\
             \n\
             (deftest smoke-test\n  (is (= 4 (core/smoke))))\n"
        );
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join("src").join(&ns).join("core.clj"), &core, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("test").join(&ns).join("core_test.clj"), &test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct SwiftSpmStarter;
impl GreenCiStarter for SwiftSpmStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        // Sources/<Name>/<Name>.swift + Tests/<Name>Tests/<Name>Tests.swift
        // Swift module convention uses the package name verbatim.
        let lib = "// pleme-io typed wrapper — auto-generated by caixa-forge.\n\
                   public enum Smoke {\n\
                   \x20   public static func value() -> Int { 4 }\n\
                   }\n";
        let mut test = String::from("import XCTest\n@testable import ");
        test.push_str(name);
        test.push_str("\n\nfinal class SmokeTests: XCTestCase {\n");
        test.push_str("    func testSmoke() { XCTAssertEqual(Smoke.value(), 4) }\n");
        test.push_str("}\n");

        let lib_path = out_root.join("Sources").join(name).join(format!("{name}.swift"));
        let test_path = out_root.join("Tests").join(format!("{name}Tests")).join(format!("{name}Tests.swift"));

        let mut out = vec![];
        if let Some(p) = write_if_needed(&lib_path, lib, force)? { out.push(p); }
        if let Some(p) = write_if_needed(&test_path, &test, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct ElixirMixStarter;
impl GreenCiStarter for ElixirMixStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = name.replace('-', "_");
        // PascalCase: my-thing -> MyThing
        let pascal: String = mod_name.split('_').map(|p| {
            let mut c = p.chars();
            c.next().map(|f| f.to_uppercase().chain(c).collect::<String>())
                .unwrap_or_default()
        }).collect();

        let mut lib = String::from("# pleme-io typed wrapper — auto-generated by caixa-forge.\ndefmodule ");
        lib.push_str(&pascal);
        lib.push_str(" do\n  def smoke, do: 4\nend\n");

        let mut test = String::from("defmodule ");
        test.push_str(&pascal);
        test.push_str("Test do\n  use ExUnit.Case\n\n  test \"smoke\" do\n    assert ");
        test.push_str(&pascal);
        test.push_str(".smoke() == 4\n  end\nend\n");

        let test_helper = "ExUnit.start()\n";

        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join(format!("lib/{mod_name}.ex")), &lib, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join(format!("test/{mod_name}_test.exs")), &test, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("test/test_helper.exs"), test_helper, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct RubyGemStarter;
impl GreenCiStarter for RubyGemStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        let mod_name = name.replace('-', "_");
        let mut lib = String::from("# pleme-io typed wrapper — auto-generated by caixa-forge.\n");
        lib.push_str("module ");
        // Capitalize each part: my-thing -> MyThing
        let pascal: String = mod_name.split('_').map(|p| {
            let mut c = p.chars();
            c.next().map(|f| f.to_uppercase().chain(c).collect::<String>())
                .unwrap_or_default()
        }).collect();
        lib.push_str(&pascal);
        lib.push_str("\n  def self.smoke\n    4\n  end\nend\n");

        let mut spec = String::from("require \"minitest/autorun\"\nrequire_relative \"../lib/");
        spec.push_str(&mod_name);
        spec.push_str("\"\n\nclass SmokeTest < Minitest::Test\n  def test_smoke\n    assert_equal 4, ");
        spec.push_str(&pascal);
        spec.push_str(".smoke\n  end\nend\n");

        let mut out = vec![];
        let mut lib_path = out_root.join("lib");
        lib_path.push(format!("{mod_name}.rb"));
        if let Some(p) = write_if_needed(&lib_path, &lib, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("test/test_smoke.rb"), &spec, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct OcamlDuneStarter;
impl GreenCiStarter for OcamlDuneStarter {
    fn write(&self, out_root: &Path, name: &str, force: bool) -> Result<Vec<PathBuf>> {
        // OCaml + dune: lib/dune for the library + lib/<name>.ml +
        // test/dune + test/test_smoke.ml. Replaces - with _ for OCaml
        // identifier conventions.
        let mod_name = name.replace('-', "_");
        let lib_dune = format!("(library\n (name {mod_name})\n (public_name {name}))\n");
        let lib_ml = "(* pleme-io typed wrapper — auto-generated by caixa-forge. *)\nlet smoke () = 4\n";
        let test_dune = format!("(test\n (name test_smoke)\n (libraries {mod_name}))\n");
        let mut test_ml = String::from("let () =\n  assert (");
        test_ml.push_str(&mod_name);
        test_ml.push_str(".smoke () = 4)\n");

        let mut out = vec![];
        if let Some(p) = write_if_needed(&out_root.join("lib/dune"), &lib_dune, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join(format!("lib/{mod_name}.ml")), lib_ml, force)? { out.push(p); }
        if let Some(p) = write_if_needed(&out_root.join("test/dune"), &test_dune, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("test/test_smoke.ml"), &test_ml, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct DotnetCsprojStarter;
impl GreenCiStarter for DotnetCsprojStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        // dotnet test conventionally finds xUnit tests via a sibling
        // test project. Operator can extend; the starter ships a
        // single Program.cs with Main that prints + a sibling smoke
        // test file consumable by `dotnet test`.
        let program = "// pleme-io typed wrapper — auto-generated by caixa-forge.\n\
                       using System;\n\
                       \n\
                       namespace Pleme.Wrapper;\n\
                       \n\
                       public static class Program\n\
                       {\n\
                       \x20   public static int Smoke() => 4;\n\
                       \n\
                       \x20   public static void Main() => Console.WriteLine(\"pleme-io wrapper stub\");\n\
                       }\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(&out_root.join("Program.cs"), program, force)? { out.push(p); }
        Ok(out)
    }
}

pub struct HelmStarter;
impl GreenCiStarter for HelmStarter {
    fn write(&self, out_root: &Path, _name: &str, force: bool) -> Result<Vec<PathBuf>> {
        // Minimal helm-lint-clean template set:
        //   templates/_helpers.tpl    — chart-name/full-name helpers
        //   templates/deployment.yaml — typed-conditional Deployment
        // values.yaml + Chart.yaml already emitted by render_helm.
        let helpers = "{{/*\n\
                       Expand the name of the chart.\n\
                       */}}\n\
                       {{- define \"chart.name\" -}}\n\
                       {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix \"-\" -}}\n\
                       {{- end -}}\n\
                       \n\
                       {{/*\n\
                       Create a default fully qualified app name.\n\
                       */}}\n\
                       {{- define \"chart.fullname\" -}}\n\
                       {{- $name := default .Chart.Name .Values.nameOverride -}}\n\
                       {{- printf \"%s-%s\" .Release.Name $name | trunc 63 | trimSuffix \"-\" -}}\n\
                       {{- end -}}\n";
        let deployment = "{{- if .Values.enabled -}}\n\
                          apiVersion: apps/v1\n\
                          kind: Deployment\n\
                          metadata:\n\
                          \x20 name: {{ include \"chart.fullname\" . }}\n\
                          \x20 labels:\n\
                          \x20\x20\x20 app.kubernetes.io/name: {{ include \"chart.name\" . }}\n\
                          spec:\n\
                          \x20 replicas: {{ .Values.replicaCount }}\n\
                          \x20 selector:\n\
                          \x20\x20\x20 matchLabels:\n\
                          \x20\x20\x20\x20\x20 app.kubernetes.io/name: {{ include \"chart.name\" . }}\n\
                          \x20 template:\n\
                          \x20\x20\x20 metadata:\n\
                          \x20\x20\x20\x20\x20 labels:\n\
                          \x20\x20\x20\x20\x20\x20\x20 app.kubernetes.io/name: {{ include \"chart.name\" . }}\n\
                          \x20\x20\x20 spec:\n\
                          \x20\x20\x20\x20\x20 containers:\n\
                          \x20\x20\x20\x20\x20\x20\x20 - name: app\n\
                          \x20\x20\x20\x20\x20\x20\x20\x20\x20 image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n\
                          {{- end }}\n";
        let mut out = vec![];
        if let Some(p) = write_if_needed(
            &out_root.join("templates/_helpers.tpl"), helpers, force)? { out.push(p); }
        if let Some(p) = write_if_needed(
            &out_root.join("templates/deployment.yaml"), deployment, force)? { out.push(p); }
        Ok(out)
    }
}

/// No-op starter — for ecosystems without green-CI support yet.
/// Returns an empty file list; render_<eco> skips the starter step.
pub struct NoStarter;
impl GreenCiStarter for NoStarter {
    fn write(&self, _out_root: &Path, _name: &str, _force: bool) -> Result<Vec<PathBuf>> {
        Ok(vec![])
    }
}

/// Dispatch by ecosystem keyword — one match arm per supported
/// ecosystem. Future ecosystems plug in here (matrix test forces
/// coverage when scaffold defaults gain a new ecosystem).
pub fn starter_for(ecosystem: &str) -> &'static dyn GreenCiStarter {
    match ecosystem {
        "rust-single-crate" | "rust-workspace" => &RustStarter,
        "go"                                    => &GoStarter,
        "npm" | "js-pnpm"                       => &NpmStarter,
        "python" | "python-pdm"                 => &PythonStarter,
        "helm"                                  => &HelmStarter,
        "ruby-gem"                              => &RubyGemStarter,
        "ocaml-dune"                            => &OcamlDuneStarter,
        "dotnet-csproj"                         => &DotnetCsprojStarter,
        "swift-spm"                             => &SwiftSpmStarter,
        "elixir-mix"                            => &ElixirMixStarter,
        "scala-sbt"                             => &ScalaSbtStarter,
        "clojure-deps"                          => &ClojureDepsStarter,
        "crystal"                               => &CrystalStarter,
        "dart"                                  => &DartStarter,
        "composer"                              => &ComposerStarter,
        "julia"                                 => &JuliaStarter,
        "zig"                                   => &ZigStarter,
        "fortran-fpm"                           => &FortranFpmStarter,
        "gleam"                                 => &GleamStarter,
        "racket-info"                           => &RacketInfoStarter,
        "java-maven"                            => &JavaMavenStarter,
        "js-deno"                               => &JsDenoStarter,
        "cpp-vcpkg"                             => &CppVcpkgStarter,
        "python-conda"                          => &PythonCondaStarter,
        "ada-alire"                             => &AdaAlireStarter,
        "java-gradle-kts"                       => &JavaGradleKtsStarter,
        "cpp-cmake"                             => &CppCmakeStarter,
        "cpp-meson"                             => &CppMesonStarter,
        "cpp-conan"                             => &CppConanStarter,
        "haskell-cabal"                         => &HaskellCabalStarter,
        "nim-nimble"                            => &NimNimbleStarter,
        "lua-rockspec"                          => &LuaRockspecStarter,
        "r-description"                         => &RDescriptionStarter,
        "python-pipenv"                         => &PythonPipenvStarter,
        "github-action"                         => &GithubActionStarter,
        _                                       => &NoStarter,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn tmp() -> tempdir::TempDir {
        tempdir::TempDir::new("green-ci").expect("tempdir")
    }

    #[test]
    fn rust_starter_emits_lib_rs_with_smoke() {
        let dir = tmp();
        let files = RustStarter.write(dir.path(), "x", true).unwrap();
        assert_eq!(files.len(), 1);
        let body = fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
        assert!(body.contains("fn smoke"));
        assert!(body.contains("assert_eq!(2 + 2, 4)"));
    }

    #[test]
    fn go_starter_emits_main_and_test() {
        let dir = tmp();
        let files = GoStarter.write(dir.path(), "x", true).unwrap();
        assert_eq!(files.len(), 2);
        assert!(dir.path().join("main.go").is_file());
        let test = fs::read_to_string(dir.path().join("main_test.go")).unwrap();
        assert!(test.contains("func TestSmoke"));
    }

    #[test]
    fn npm_starter_emits_index_and_test() {
        let dir = tmp();
        let files = NpmStarter.write(dir.path(), "x", true).unwrap();
        assert_eq!(files.len(), 2);
        assert!(dir.path().join("src/index.js").is_file());
        let test = fs::read_to_string(dir.path().join("test/test.js")).unwrap();
        assert!(test.contains("test('smoke',"));
    }

    #[test]
    fn python_starter_substitutes_name_safely_via_typed_ast() {
        let dir = tmp();
        let files = PythonStarter.write(dir.path(), "my-cool-pkg", true).unwrap();
        assert_eq!(files.len(), 2);
        // Hyphens become underscores per Python module-name convention.
        assert!(dir.path().join("src/my_cool_pkg/__init__.py").is_file());
        let test = fs::read_to_string(dir.path().join("tests/test_smoke.py")).unwrap();
        assert!(test.contains("from my_cool_pkg import smoke"));
        assert!(test.contains("class SmokeTest(unittest.TestCase):"));
        // The __main__ guard MUST come AFTER the class (orderly emission
        // via python_ast::File::push_after_class — not before).
        let class_pos = test.find("class SmokeTest").unwrap();
        let main_pos = test.find("__main__").unwrap();
        assert!(main_pos > class_pos,
            "__main__ guard should follow class def; got: {test}");
    }

    #[test]
    fn write_is_idempotent_when_force_false_and_file_exists() {
        let dir = tmp();
        // First write: file created.
        let first = RustStarter.write(dir.path(), "x", false).unwrap();
        assert_eq!(first.len(), 1);
        // Tamper the file content + second write must NOT overwrite.
        fs::write(dir.path().join("src/lib.rs"), "// tampered\n").unwrap();
        let second = RustStarter.write(dir.path(), "x", false).unwrap();
        assert_eq!(second.len(), 0, "expected no write when force=false + file exists");
        let body = fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
        assert_eq!(body, "// tampered\n", "tampered content should be preserved");
    }

    #[test]
    fn starter_for_returns_no_starter_for_unknown_ecosystem() {
        let s = starter_for("some-future-ecosystem");
        let dir = tmp();
        let files = s.write(dir.path(), "x", true).unwrap();
        assert!(files.is_empty(), "unknown ecosystem should get NoStarter");
    }

    #[test]
    fn helm_starter_emits_template_files() {
        let dir = tmp();
        let files = HelmStarter.write(dir.path(), "x", true).unwrap();
        assert_eq!(files.len(), 2);
        assert!(dir.path().join("templates/_helpers.tpl").is_file());
        let dep = fs::read_to_string(dir.path().join("templates/deployment.yaml")).unwrap();
        assert!(dep.contains("apiVersion: apps/v1"));
        assert!(dep.contains("kind: Deployment"));
        assert!(dep.contains("include \"chart.fullname\""));
    }

    #[test]
    fn starter_for_dispatches_rust_correctly() {
        let s = starter_for("rust-single-crate");
        let dir = tmp();
        let files = s.write(dir.path(), "x", true).unwrap();
        assert_eq!(files.len(), 1);
        assert!(dir.path().join("src/lib.rs").is_file());
    }
}