autumn-cli 0.7.0

CLI tool for the Autumn web framework
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
//! CSV export route + download link in scaffolded list views (issue #1315).
//!
//! `autumn generate scaffold` emits a `CsvSchema` impl covering every scaffolded
//! column, a `GET /{plural}/export.csv` handler that streams the index's row set
//! through `autumn_web::data::csv::export_csv` as an `attachment` download, an
//! "Export CSV" link on the index that carries the active sort/filter query, and
//! a generated test asserting the download contract. The export mirrors the
//! index's row-set query branch for branch, so the `--api`, `--live`,
//! `--sharded` and owner-scoped `--live-validation` variants — whose indexes run
//! a query the export cannot reuse verbatim — are deliberately gated off.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const fn autumn_bin() -> &'static str {
    env!("CARGO_BIN_EXE_autumn")
}

fn run_autumn_ok(dir: &Path, args: &[&str]) {
    let output = Command::new(autumn_bin())
        .args(args)
        .current_dir(dir)
        .output()
        .expect("failed to run autumn");
    assert!(
        output.status.success(),
        "autumn {args:?} failed (exit={:?})\nstdout: {}\nstderr: {}",
        output.status.code(),
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );
}

/// `autumn new` + `generate scaffold Post <cols> [extra_flags]`, returning the
/// tempdir guard and the generated project root.
fn scaffold_project(
    name: &str,
    cols: &[&str],
    extra_flags: &[&str],
) -> (tempfile::TempDir, PathBuf) {
    let tmp = tempfile::tempdir().expect("tempdir");
    run_autumn_ok(tmp.path(), &["new", name]);
    let project = tmp.path().join(name);
    let mut args = vec!["generate", "scaffold", "Post"];
    args.extend_from_slice(cols);
    args.extend_from_slice(extra_flags);
    run_autumn_ok(&project, &args);
    (tmp, project)
}

/// The default three-column scaffold used by most cases here.
fn default_cols() -> Vec<&'static str> {
    vec!["title:String", "body:Text", "published:bool"]
}

/// `scaffold_project`, returning the generated `src/routes/posts.rs` source.
fn scaffold_routes(name: &str, extra_flags: &[&str]) -> (tempfile::TempDir, String) {
    let (tmp, project) = scaffold_project(name, &default_cols(), extra_flags);
    let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
    (tmp, routes)
}

/// Slice out a named `pub async fn` handler: its signature through the line
/// before the next top-level handler.
fn handler_slice<'a>(routes: &'a str, name: &str) -> &'a str {
    let needle = format!("pub async fn {name}(");
    let start = routes
        .find(&needle)
        .unwrap_or_else(|| panic!("routes file must emit `{needle}`:\n{routes}"));
    let rest = &routes[start + needle.len()..];
    rest.split("pub async fn ").next().unwrap_or(rest)
}

/// The `impl … CsvSchema for Post { … }` block.
fn csv_schema_impl(routes: &str) -> &str {
    let start = routes
        .find("CsvSchema for Post")
        .unwrap_or_else(|| panic!("routes file must emit a CsvSchema impl:\n{routes}"));
    let rest = &routes[start..];
    // The impl is followed by the export handler's doc comment.
    rest.split("\n/// `GET").next().unwrap_or(rest)
}

// ── AC1: a CsvSchema impl covering every scaffolded column ────────────────────

#[test]
fn plain_scaffold_emits_a_csv_schema_impl_for_the_model() {
    let (_tmp, routes) = scaffold_routes("csv-plain", &[]);
    assert!(
        routes.contains("impl autumn_web::data::csv::CsvSchema for Post {"),
        "a CsvSchema impl must be emitted for the model:\n{routes}"
    );
    let schema = csv_schema_impl(&routes);
    assert!(
        schema.contains("fn csv_columns() -> &'static [&'static str] {"),
        "{schema}"
    );
    assert!(
        schema.contains("fn to_csv_record(&self) -> Vec<String> {"),
        "{schema}"
    );
}

#[test]
fn csv_columns_cover_every_scaffolded_column_in_declaration_order() {
    let (_tmp, routes) = scaffold_routes("csv-columns", &[]);
    let schema = csv_schema_impl(&routes);
    // `created_at` closes the list, matching what the `show` view renders.
    assert!(
        schema.contains(r#"&["id", "title", "body", "published", "created_at"]"#),
        "csv_columns must list id + every scaffolded column in order:\n{schema}"
    );
    // The record must be built in exactly the same order, one slot per header.
    let record = schema
        .split_once("fn to_csv_record")
        .expect("to_csv_record")
        .1;
    let id_at = record.find("self.id").expect("id slot");
    let title_at = record.find("self.title").expect("title slot");
    let body_at = record.find("self.body").expect("body slot");
    let published_at = record.find("self.published").expect("published slot");
    let created_at = record.find("self.created_at").expect("created_at slot");
    assert!(
        id_at < title_at
            && title_at < body_at
            && body_at < published_at
            && published_at < created_at,
        "to_csv_record slots must follow csv_columns order:\n{record}"
    );
    // One value per header, no more and no less.
    assert_eq!(
        record.matches("self.").count(),
        5,
        "to_csv_record must emit exactly one slot per csv_columns entry:\n{record}"
    );
}

// ── AC7: nullable columns are an empty cell, never the string `None` ──────────

#[test]
fn nullable_columns_serialize_to_an_empty_cell() {
    let (_tmp, project) = scaffold_project(
        "csv-nullable",
        &[
            "title:String",
            "subtitle:Option<String>",
            "views:Option<i64>",
            "token:Option<Uuid>",
        ],
        &[],
    );
    let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
    let schema = csv_schema_impl(&routes);
    for slot in [
        "self.subtitle.clone().unwrap_or_default()",
        "self.views.as_ref().map(ToString::to_string).unwrap_or_default()",
        "self.token.as_ref().map(ToString::to_string).unwrap_or_default()",
    ] {
        assert!(
            schema.contains(slot),
            "nullable column must serialize to an empty cell via `{slot}`:\n{schema}"
        );
    }
    // A debug-formatted `Option` would print the literal `None` into the cell.
    let debug_fmt = concat!('{', ":?", '}');
    assert!(
        !schema.contains(debug_fmt),
        "a debug-formatted Option would export the literal `None`:\n{schema}"
    );
}

// ── AC2: the streaming export route + its headers ─────────────────────────────

#[test]
fn plain_scaffold_emits_the_export_route() {
    let (_tmp, routes) = scaffold_routes("csv-route", &[]);
    assert!(
        routes.contains("#[get(\"/posts/export.csv\")]"),
        "an export route must be emitted:\n{routes}"
    );
    let export = handler_slice(&routes, "export_csv");
    assert!(
        export.contains("autumn_web::data::csv::export_csv("),
        "the export handler must stream rows through export_csv:\n{export}"
    );
    assert!(
        export.contains("autumn_web::download::Download::from_bytes("),
        "the export handler must answer with a Download:\n{export}"
    );
    assert!(
        export.contains(r#".filename("posts.csv")"#),
        "the download must be named `{{plural}}.csv`:\n{export}"
    );
}

// ── AC4: the export honours the index's allowlisted sort/filter ───────────────

#[test]
fn export_reflects_the_filtered_view_not_find_all() {
    let (_tmp, routes) = scaffold_routes("csv-filtered", &[]);
    let export = handler_slice(&routes, "export_csv");
    assert!(
        export.contains("list_query: ListQuery,"),
        "the export must extract the same ListQuery as the index:\n{export}"
    );
    assert!(
        export.contains("repo.list(&list_query,"),
        "the export must apply the allowlisted sort/filter via repo.list:\n{export}"
    );
    assert!(
        !export.contains("find_all("),
        "the export must not unconditionally load every row:\n{export}"
    );
}

#[test]
fn export_is_bounded_by_a_row_cap_and_reads_in_pages() {
    let (_tmp, routes) = scaffold_routes("csv-bounded", &[]);
    assert!(
        routes.contains("const MAX_EXPORT_ROWS: usize"),
        "the export must carry an explicit row cap:\n{routes}"
    );
    let export = handler_slice(&routes, "export_csv");
    assert!(
        export.contains("autumn_web::pagination::MAX_PAGE_SIZE"),
        "the export must read in MAX_PAGE_SIZE batches:\n{export}"
    );
    assert!(
        export.contains("MAX_EXPORT_ROWS"),
        "the export must stop at the row cap:\n{export}"
    );
}

#[test]
fn export_reads_past_the_cap_so_truncation_is_detectable() {
    // Breaking on `rows.len() >= MAX_EXPORT_ROWS` fills the cap EXACTLY and so
    // never observes a row beyond it: `truncated` stays false, no warning is
    // logged and no `x-export-truncated` header is set, while the rows past the
    // cap are dropped anyway. Every over-cap export would then be silently
    // short — the precise failure the truncation signal exists to prevent. The
    // loop must read past the cap and decide truncation strictly.
    let (_tmp, routes) = scaffold_routes("csv-truncation", &[]);
    let export = handler_slice(&routes, "export_csv");
    assert!(
        !export.contains(">= MAX_EXPORT_ROWS"),
        "stopping once the cap is filled cannot distinguish a complete export \
         from a truncated one:\n{export}"
    );
    assert!(
        export.contains("if exhausted || rows.len() > MAX_EXPORT_ROWS"),
        "the export loop must read past the cap before stopping:\n{export}"
    );
    assert!(
        export.contains("let truncated = rows.len() > MAX_EXPORT_ROWS;"),
        "truncation must be decided by a strict over-cap comparison:\n{export}"
    );
    assert!(
        export.contains("rows.truncate(MAX_EXPORT_ROWS);"),
        "the surplus read past the cap must be trimmed before the CSV is \
         written:\n{export}"
    );
}

// ── AC3: the index renders an "Export CSV" link ───────────────────────────────

#[test]
fn index_renders_an_export_csv_link() {
    let (_tmp, routes) = scaffold_routes("csv-link", &[]);
    let index = handler_slice(&routes, "index");
    assert!(
        index.contains("\"Export CSV\""),
        "the index must render an Export CSV link:\n{index}"
    );
    assert!(
        index.contains("paths::export_csv()"),
        "the link must point at the typed export path:\n{index}"
    );
}

#[test]
fn export_link_preserves_the_active_sort_and_filter() {
    let (_tmp, routes) = scaffold_routes("csv-link-query", &[]);
    let index = handler_slice(&routes, "index");
    // The href is built FROM the raw query, not merely near a `pager_query`
    // binding (which every generated index has carried since the pager landed).
    assert!(
        index.contains(r#"format!("{}?{}", paths::export_csv(), pager_query)"#),
        "the export href must append the current sort/filter query string:\n{index}"
    );
    // ...and stays clean when there is no query to carry.
    assert!(
        index.contains("if pager_query.is_empty() {"),
        "an unfiltered index must link at the bare export path:\n{index}"
    );
    // Maud drops template whitespace, so the two links need an explicit
    // separator or they render as one glued run ("New PostExport CSV").
    assert!(
        index.contains(
            "\"New Post\"))\n        \" \"\n        (autumn_web::a11y::Link::new(export_href, \"Export CSV\"))"
        ),
        "the Export CSV link must be separated from the New Post link:\n{index}"
    );
}

#[test]
fn searchable_index_puts_the_export_link_inside_the_swapped_results_container() {
    // `active_search_input` swaps `#posts-search-results` and pushes no URL, so
    // a search term never reaches `pager_query` and `ListQuery` has no field for
    // one. A link rendered as page furniture would therefore survive the swap
    // still pointing at the UNSEARCHED row set: narrow the list, click "Export
    // CSV" beside the rows you kept, download the rows you excluded. Rendering
    // it inside the container makes the swap take it away with the rows it
    // described.
    let (_tmp, routes) = scaffold_routes("csv-searchable-link", &["--searchable", "title"]);
    let index = handler_slice(&routes, "index");
    assert!(
        index.contains("\"Export CSV\""),
        "a searchable scaffold still exports its unsearched list:\n{index}"
    );
    let container = index
        .find(r#"div id="posts-search-results""#)
        .expect("premise: a searchable index renders the results container");
    let link = index
        .find("\"Export CSV\"")
        .expect("checked non-empty above");
    assert!(
        link > container,
        "the Export CSV link must sit inside the swapped results container, \
         not above it as page furniture:\n{index}"
    );
    // ...and specifically NOT glued to the "New Post" anchor, which lives
    // outside the container and survives every swap.
    assert!(
        !index.contains(
            "\"New Post\"))\n        \" \"\n        (autumn_web::a11y::Link::new(export_href"
        ),
        "a searchable index must not render the export link as furniture:\n{index}"
    );
}

#[test]
fn searchable_results_fragment_renders_no_export_link() {
    // The other half of the contract: the fragment that REPLACES the container
    // carries no link, so searched results offer no export rather than a wrong
    // one. This is what docs/guide/generators.md promises.
    let (_tmp, routes) = scaffold_routes("csv-searchable-frag", &["--searchable", "title"]);
    let search = handler_slice(&routes, "search");
    assert!(
        !search.contains("Export CSV"),
        "the search results fragment must not offer an export it cannot scope:\n{search}"
    );
}

#[test]
fn paths_module_exposes_the_export_helper() {
    let (_tmp, routes) = scaffold_routes("csv-paths", &[]);
    let paths = routes
        .split_once("autumn_web::paths![")
        .expect("paths! block")
        .1
        .split_once("];")
        .expect("paths! block end")
        .0;
    assert!(
        paths.contains("export_csv"),
        "the paths! block must export the csv helper:\n{paths}"
    );
}

#[test]
fn export_route_is_mounted_in_main() {
    let (_tmp, project) = scaffold_project("csv-main", &default_cols(), &[]);
    let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
    assert!(
        main.contains("routes::posts::export_csv"),
        "the export route must be mounted:\n{main}"
    );
}

// ── AC5: same security posture as the index ───────────────────────────────────

#[test]
fn export_mirrors_the_index_security_posture() {
    // No owner column: the generated index carries no `#[secured]`, so neither
    // does the export — it opens no new public data path either way.
    let (_tmp, routes) = scaffold_routes("csv-posture", &[]);
    // Pin the export's existence FIRST: without this the two `contains` bools
    // below are both `false` when the feature is deleted, and the equality
    // passes vacuously.
    assert!(
        routes.contains("#[get(\"/posts/export.csv\")]"),
        "the export route must exist for its posture to mean anything:\n{routes}"
    );
    let index_secured = routes.contains("#[secured]\n#[get(\"/posts\")]");
    let export_secured = routes.contains("#[secured]\n#[get(\"/posts/export.csv\")]");
    assert!(
        !index_secured,
        "a no-owner scaffold's index is expected to be unsecured; if that changed, \
         this test's premise needs revisiting:\n{routes}"
    );
    assert_eq!(
        index_secured, export_secured,
        "the export must carry exactly the index's #[secured] posture:\n{routes}"
    );
}

#[test]
fn export_carries_a_per_ip_throttle_the_index_does_not_need() {
    // One export reads up to MAX_EXPORT_ROWS rows over ~100 page queries plus
    // ~100 filtered COUNT(*)s (`list` counts before every page), where one index
    // page costs two round trips — so on an unsecured scaffold the route is a
    // large cost amplifier on traffic the index already accepts.
    let (_tmp, routes) = scaffold_routes("csv-throttle", &[]);
    assert!(
        routes.contains("#[autumn_web::throttle(limit = 6, per = \"1m\", key = \"ip\")]"),
        "the export must carry a per-IP throttle:\n{routes}"
    );
    // Fully qualified so no import is needed — a bare `#[throttle]` would not
    // resolve in the generated module.
    assert!(
        !routes.contains("\n#[throttle("),
        "the throttle attribute must be fully qualified:\n{routes}"
    );
}

#[test]
fn text_columns_are_guarded_against_spreadsheet_formula_injection() {
    // RFC 4180 (which `export_csv` implements) governs commas/quotes/newlines
    // and says nothing about formulas; Excel evaluates a leading `=` even
    // inside quotes.
    let (_tmp, project) = scaffold_project(
        "csv-formula",
        &["title:String", "views:i64", "at:DateTime"],
        &[],
    );
    let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
    assert!(
        routes.contains("fn csv_text_cell(value: String) -> String {"),
        "a formula guard helper must be emitted:\n{routes}"
    );
    assert!(
        routes.contains("value.starts_with(['=', '+', '-', '@', '\\t', '\\r'])"),
        "{routes}"
    );
    let schema = csv_schema_impl(&routes);
    assert!(
        schema.contains("csv_text_cell(self.title.clone())"),
        "a text column must go through the guard:\n{schema}"
    );
    // Typed columns cannot carry a formula, and guarding them would prefix a
    // legitimate negative number.
    assert!(
        schema.contains("            self.views.to_string(),"),
        "a numeric column must NOT be guarded:\n{schema}"
    );
    assert!(
        schema.contains("            self.at.to_string(),"),
        "a timestamp column must NOT be guarded:\n{schema}"
    );
}

#[test]
fn scaffold_without_text_columns_omits_the_unused_guard_helper() {
    // An unused `fn` is a `dead_code` warning, and generated code must compile
    // warning-free.
    let (_tmp, project) = scaffold_project("csv-no-text", &["views:i64", "ok:bool"], &[]);
    let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
    assert!(
        routes.contains("#[get(\"/posts/export.csv\")]"),
        "the export is still emitted:\n{routes}"
    );
    assert!(
        !routes.contains("fn csv_text_cell"),
        "no text column means no guard helper:\n{routes}"
    );
}

#[test]
fn soft_delete_scaffold_keeps_deleted_at_out_of_the_export() {
    // `list`/`list_scoped` filter `deleted_at IS NULL`, so the column is NULL
    // for every row that can reach the export — an always-blank spreadsheet
    // column is noise.
    let (_tmp, project) = scaffold_project("csv-soft", &default_cols(), &["--soft-delete"]);
    let model = fs::read_to_string(project.join("src/models/post.rs")).unwrap();
    assert!(
        model.contains("deleted_at"),
        "premise: --soft-delete puts deleted_at on the model:\n{model}"
    );
    let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
    let schema = csv_schema_impl(&routes);
    assert!(
        !schema.contains("deleted_at"),
        "deleted_at must not be an exported column:\n{schema}"
    );
    let test = fs::read_to_string(project.join("tests/post.rs")).unwrap();
    assert!(
        !test.contains("\"deleted_at\""),
        "the generated CSV test's header must match the impl:\n{test}"
    );
}

#[test]
fn owner_scoped_export_never_calls_the_unscoped_list() {
    let (_tmp, project) = scaffold_project(
        "csv-owner",
        &["title:String", "body:Text", "user:references"],
        &[],
    );
    let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
    assert!(
        routes.contains("#[secured]\n#[get(\"/posts/export.csv\")]"),
        "an owner-scoped export must be #[secured] like its index:\n{routes}"
    );
    let export = handler_slice(&routes, "export_csv");
    assert!(
        export.contains("repo.list_scoped(owner_id, &list_query,"),
        "an owner-scoped export must go through list_scoped:\n{export}"
    );
    assert!(
        !export.contains("repo.list(&list_query"),
        "an owner-scoped export must never call the unscoped list:\n{export}"
    );
}

// ── The `csv` Cargo feature ───────────────────────────────────────────────────

#[test]
fn scaffold_enables_the_autumn_web_csv_feature() {
    let (_tmp, project) = scaffold_project("csv-feature", &default_cols(), &[]);
    let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
    let dep_line = cargo
        .lines()
        .find(|l| l.starts_with("autumn-web = {"))
        .unwrap_or_else(|| panic!("no autumn-web dependency line:\n{cargo}"));
    assert!(
        dep_line.contains("\"csv\""),
        "the scaffold must enable autumn-web's csv feature:\n{dep_line}"
    );
}

#[test]
fn destroy_removes_the_csv_feature_again() {
    let (_tmp, project) = scaffold_project("csv-destroy", &default_cols(), &[]);
    run_autumn_ok(&project, &["destroy", "scaffold", "Post", "--force"]);
    let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
    assert!(
        !cargo.contains("\"csv\""),
        "destroy must remove the csv feature it added:\n{cargo}"
    );
}

#[test]
fn destroy_keeps_the_csv_feature_when_hand_written_code_still_uses_it() {
    // `import_csv` has no generator at all, so an author wiring an upload form
    // is doing it by hand by definition. Destroying the last scaffolded
    // resource must not strip the feature out from under them — the same rule
    // `storage`/`multipart` got in #1867.
    let (_tmp, project) = scaffold_project("csv-destroy-keep", &default_cols(), &[]);
    fs::write(
        project.join("src/reports.rs"),
        "use autumn_web::data::csv::export_csv;\n\
         pub fn write_report(out: &mut Vec<u8>) {\n    \
         let _ = export_csv(Vec::<crate::models::post::Post>::new(), out);\n\
         }\n",
    )
    .unwrap();
    run_autumn_ok(&project, &["destroy", "scaffold", "Post", "--force"]);
    let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
    assert!(
        cargo.contains("\"csv\""),
        "destroy must keep a feature hand-written code still references:\n{cargo}"
    );
}

#[test]
fn destroy_keeps_the_csv_feature_for_a_module_level_import() {
    // The sibling test above imports an ITEM (`…::csv::export_csv`), so its
    // `use` line carries the `autumn_web::data::csv::` prefix. Importing the
    // MODULE instead is just as ordinary — and that line ends at the module
    // name, with no trailing `::` — so a marker requiring the separator would
    // read this file as "csv unused", strip the feature, and leave
    // `reports.rs` unable to compile.
    let (_tmp, project) = scaffold_project("csv-destroy-mod", &default_cols(), &[]);
    fs::write(
        project.join("src/reports.rs"),
        "use autumn_web::data::csv;\n\
         pub fn write_report(out: &mut Vec<u8>) {\n    \
         let _ = csv::export_csv(Vec::<crate::models::post::Post>::new(), out);\n\
         }\n",
    )
    .unwrap();
    run_autumn_ok(&project, &["destroy", "scaffold", "Post", "--force"]);
    let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
    assert!(
        cargo.contains("\"csv\""),
        "a module-level `use autumn_web::data::csv;` still needs the feature:\n{cargo}"
    );
}

#[test]
fn destroy_keeps_the_csv_feature_for_a_renamed_module_import() {
    // `use … as data_csv;` ends at the rename, not at `::` either.
    let (_tmp, project) = scaffold_project("csv-destroy-alias", &default_cols(), &[]);
    fs::write(
        project.join("src/reports.rs"),
        "use autumn_web::data::csv as data_csv;\n\
         pub fn write_report(out: &mut Vec<u8>) {\n    \
         let _ = data_csv::export_csv(Vec::<crate::models::post::Post>::new(), out);\n\
         }\n",
    )
    .unwrap();
    run_autumn_ok(&project, &["destroy", "scaffold", "Post", "--force"]);
    let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
    assert!(
        cargo.contains("\"csv\""),
        "a renamed module import still needs the feature:\n{cargo}"
    );
}

#[test]
fn destroy_removes_the_csv_feature_when_the_surviving_resource_has_no_export() {
    // "some routes file still exists" is the wrong question for `csv`: only an
    // EXPORT-ENABLED resource needs it, so a surviving `--live` module must not
    // pin the feature (and the `csv` crate) in the graph forever.
    let (_tmp, project) = scaffold_project("csv-destroy-live", &default_cols(), &[]);
    run_autumn_ok(
        &project,
        &["generate", "scaffold", "Item", "name:String", "--live"],
    );
    run_autumn_ok(&project, &["destroy", "scaffold", "Post", "--force"]);
    assert!(
        project.join("src/routes/items.rs").exists(),
        "premise: the --live resource survives"
    );
    let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
    assert!(
        !cargo.contains("\"csv\""),
        "a surviving export-less resource must not pin the csv feature:\n{cargo}"
    );
}

#[test]
fn destroy_keeps_the_csv_feature_while_another_export_survives() {
    let (_tmp, project) = scaffold_project("csv-destroy-two", &default_cols(), &[]);
    run_autumn_ok(
        &project,
        &["generate", "scaffold", "Article", "headline:String"],
    );
    run_autumn_ok(&project, &["destroy", "scaffold", "Post", "--force"]);
    let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
    assert!(
        cargo.contains("\"csv\""),
        "the surviving Article export still needs the csv feature:\n{cargo}"
    );
}

// ── AC6: the generated test asserts the download contract ─────────────────────

#[test]
fn generated_test_asserts_the_csv_download_contract() {
    let (_tmp, project) = scaffold_project("csv-gen-test", &default_cols(), &[]);
    let test = fs::read_to_string(project.join("tests/post.rs")).unwrap();
    assert!(
        test.contains("posts_export_csv_downloads_a_spreadsheet"),
        "a CSV export test must be generated:\n{test}"
    );
    for needle in [
        ".assert_ok()",
        r#".assert_header_contains("content-type", "text/csv")"#,
        r#".assert_header_contains("content-disposition", "attachment")"#,
        r#"filename=\"posts.csv\""#,
    ] {
        assert!(
            test.contains(needle),
            "generated CSV test must assert `{needle}`:\n{test}"
        );
    }
    // AC7, asserted rather than assumed. The stand-in row's cells are
    // `Option<String>` with real `None`s, so the "not `None`" assertion has
    // failure power over the cell expression rather than being a tautology.
    assert!(
        test.contains("cells: Vec<Option<String>>,"),
        "the generated test's rows must model NULL columns as None:\n{test}"
    );
    assert!(
        test.contains("cell.clone().unwrap_or_default()"),
        "the generated test must use the same empty-cell expression as the impl:\n{test}"
    );
    assert!(
        test.contains("!body.contains(\"None\")"),
        "generated CSV test must prove a NULL is not exported as `None`:\n{test}"
    );
    // RFC 4180 escaping and the formula guard, both asserted against the real
    // response body.
    assert!(
        test.contains(r#"body.contains("\"\"q\"\"")"#),
        "generated CSV test must prove RFC 4180 quote escaping:\n{test}"
    );
    assert!(
        test.contains(r#"body.contains("'=HYPERLINK")"#),
        "generated CSV test must prove the formula guard reaches the body:\n{test}"
    );
}

// ── Gated-off variants stay byte-identical to their pre-#1315 output ──────────

/// A gated-off variant must omit the export from the routes module, the
/// `main.rs` mount AND the Cargo feature — all three or none. The failure the
/// two "must agree exactly" gate comments exist to prevent is precisely a
/// `main.rs` entry for a handler the module never emitted, which is a compile
/// error in the user's project, so checking only the routes file would miss it.
fn assert_no_export_anywhere(project: &Path, routes_file: &str) {
    let routes = fs::read_to_string(project.join(routes_file)).unwrap_or_default();
    assert!(!routes.contains("export.csv"), "{routes}");
    assert!(!routes.contains("CsvSchema"), "{routes}");
    let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
    assert!(
        !main.contains("routes::posts::export_csv"),
        "main.rs must not mount an export the module never emitted:\n{main}"
    );
    let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
    assert!(
        !cargo.contains("\"csv\""),
        "no export means no csv feature:\n{cargo}"
    );
}

#[test]
fn live_scaffold_omits_csv_export() {
    let (_tmp, project) = scaffold_project("csv-live", &default_cols(), &["--live"]);
    assert_no_export_anywhere(&project, "src/routes/posts.rs");
}

#[test]
fn sharded_scaffold_omits_csv_export() {
    let (_tmp, project) = scaffold_project("csv-sharded", &default_cols(), &["--sharded"]);
    assert_no_export_anywhere(&project, "src/routes/posts.rs");
}

#[test]
fn owner_scoped_live_validation_scaffold_omits_csv_export() {
    // The owner-scoped `--live-validation` index runs a manual owner-filtered
    // diesel query rather than a scoped repository method, so there is no query
    // the export could reuse without re-deriving the owner filter by hand.
    let (_tmp, project) = scaffold_project(
        "csv-lv-owner",
        &["title:String", "user:references"],
        &["--live-validation"],
    );
    assert_no_export_anywhere(&project, "src/routes/posts.rs");
}

#[test]
fn plain_live_validation_scaffold_keeps_csv_export() {
    // Without an owner column `--live-validation` renders the standard
    // data_table index on `repo.list`, so the export applies unchanged.
    let (_tmp, project) = scaffold_project("csv-lv-plain", &default_cols(), &["--live-validation"]);
    let routes = fs::read_to_string(project.join("src/routes/posts.rs")).unwrap();
    assert!(routes.contains("#[get(\"/posts/export.csv\")]"), "{routes}");
    let main = fs::read_to_string(project.join("src/main.rs")).unwrap();
    assert!(main.contains("routes::posts::export_csv"), "{main}");
}

#[test]
fn api_scaffold_omits_csv_export() {
    // `--api` emits no routes module at all, so there is no file to read.
    let (_tmp, project) = scaffold_project("csv-api", &default_cols(), &["--api"]);
    assert!(!project.join("src/routes/posts.rs").exists());
    assert_no_export_anywhere(&project, "src/routes/posts.rs");
}