nest-rs-cli 1.3.0

Scaffolding CLI for NestRS — new projects, feature generators, and project health checks.
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
//! Dependency auto-wiring for generated code.
//!
//! Adding a transport adapter to a fresh workspace usually needs a crate the
//! starter `Cargo.toml` doesn't carry yet (a resource needs `nest-rs-seaorm`,
//! a GraphQL adapter needs `async-graphql`, …). These [`Transform`]s splice
//! the missing entries into the root `[workspace.dependencies]` and the
//! `crates/features` manifest — idempotently, so an already-equipped workspace
//! (the nestrs repo itself) is a no-op.

use toml_edit::{DocumentMut, Item, Value};

use crate::naming::Transport;
use crate::scaffold::Transform;
use crate::version::framework_req;

/// One dependency the generator may need to introduce.
pub(crate) struct Dep {
    name: &'static str,
    /// TOML value placed in `[workspace.dependencies]` when absent. **Ignored
    /// for `nest-rs-*` crates** — their version tracks the CLI's own release
    /// line (see [`framework_req`]), so leave it `""` for those.
    workspace_value: &'static str,
    /// Features to enable in the `features` crate (`[]` ⇒ `{ workspace = true }`).
    features: &'static [&'static str],
}

impl Dep {
    /// The `[workspace.dependencies]` value to insert. `nest-rs-*` crates pin
    /// the lockstep framework requirement; everything else uses its literal.
    fn workspace_item(&self) -> Item {
        if self.name.starts_with("nest-rs-") {
            parse_value(&format!("\"{}\"", framework_req()))
        } else {
            parse_value(self.workspace_value)
        }
    }
}

// `nest-rs-*` crates: `workspace_value` is unused — `workspace_item` derives
// the version from the CLI's own release line (`framework_req`).
const SEAORM: Dep = Dep {
    name: "nest-rs-seaorm",
    workspace_value: "",
    features: &["http"],
};
const RESOURCE: Dep = Dep {
    name: "nest-rs-resource",
    workspace_value: "",
    features: &[],
};
const GRAPHQL: Dep = Dep {
    name: "nest-rs-graphql",
    workspace_value: "",
    features: &[],
};
const WS: Dep = Dep {
    name: "nest-rs-ws",
    workspace_value: "",
    features: &[],
};
const QUEUE: Dep = Dep {
    name: "nest-rs-queue",
    workspace_value: "",
    features: &[],
};
const SCHEDULE: Dep = Dep {
    name: "nest-rs-schedule",
    workspace_value: "",
    features: &[],
};
// The connection behind `QueueConnection` / `QueueModule` / `QueueWorkerModule`.
// `nest-rs-queue` carries only the abstractions, so a generated producer that
// follows `/queue/producing-jobs/` (`use nest_rs_redis::QueueConnection;`)
// cannot compile without it — the one line the queue install stanza names that
// the generator used to skip.
const REDIS: Dep = Dep {
    name: "nest-rs-redis",
    workspace_value: "",
    features: &[],
};
const MCP: Dep = Dep {
    name: "nest-rs-mcp",
    workspace_value: "",
    features: &[],
};
// `/mcp`'s fallback operation guard — the seam that lets a registered global
// guard pool gate tool calls instead of the endpoint staying deny-all — lives
// behind `nest-rs-guards`' `mcp` feature. Same shape as `GUARDS_GRAPHQL` /
// `GUARDS_WS`: the crate is already in every scaffolded workspace, so only the
// feature is missing, and leaving it off silently reduced the two documented
// ways to authenticate `/mcp` to one.
const GUARDS_MCP: Dep = Dep {
    name: "nest-rs-guards",
    workspace_value: "",
    features: &["mcp"],
};
const AUTHN: Dep = Dep {
    name: "nest-rs-authn",
    workspace_value: "",
    features: &[],
};
const AUTHZ: Dep = Dep {
    name: "nest-rs-authz",
    workspace_value: "",
    features: &["http"],
};
// The GraphQL trio. `#[resolver]` expands to `nest_rs_guards::{GraphqlChainCell,
// GraphqlChainSources, run_layered_graphql_chain}`, which live behind that
// crate's `graphql` feature — and `nest-rs-guards` is already a dependency of
// every scaffolded `features` crate, so it is the *feature*, not the entry,
// that has to be added. The other two carry the authz bridge
// (`GraphqlAbilityBridge`) and its loader scope (`LoaderScope`); both keep
// `http` because the bridge is written against the HTTP guards.
const GUARDS_GRAPHQL: Dep = Dep {
    name: "nest-rs-guards",
    workspace_value: "",
    features: &["graphql"],
};
const AUTHZ_GRAPHQL: Dep = Dep {
    name: "nest-rs-authz",
    workspace_value: "",
    features: &["http", "graphql"],
};
const SEAORM_GRAPHQL: Dep = Dep {
    name: "nest-rs-seaorm",
    workspace_value: "",
    features: &["http", "graphql"],
};
const RESOURCE_GRAPHQL: Dep = Dep {
    name: "nest-rs-resource",
    workspace_value: "",
    features: &["graphql"],
};
// `#[messages]` expands to `nest_rs_guards::GuardAsWsMessageCheck`, which that
// crate gates behind `ws`. Same shape as `GUARDS_GRAPHQL`: every scaffolded
// workspace already depends on `nest-rs-guards`, so it is the feature that has
// to be turned on. Leaving it off compiles only by feature unification with a
// dev-dependency — `cargo check -p features` fails while `--workspace` passes.
const GUARDS_WS: Dep = Dep {
    name: "nest-rs-guards",
    workspace_value: "",
    features: &["ws"],
};
// Mirrors the feature set `nest-rs-seaorm` itself resolves — a divergent list
// (or a release-candidate floor) would be a manifest the user inherits and has
// to un-learn later.
const SEA_ORM: Dep = Dep {
    name: "sea-orm",
    workspace_value: "{ version = \"2.0\", default-features = false, features = [\"sqlx-postgres\", \"runtime-tokio-rustls\", \"macros\", \"with-uuid\", \"with-chrono\"] }",
    features: &[],
};
const SERDE: Dep = Dep {
    name: "serde",
    workspace_value: "{ version = \"1\", features = [\"derive\"] }",
    features: &[],
};
const UUID: Dep = Dep {
    name: "uuid",
    workspace_value: "{ version = \"1\", features = [\"v7\", \"serde\"] }",
    features: &[],
};
const VALIDATOR: Dep = Dep {
    name: "validator",
    workspace_value: "{ version = \"0.20\", features = [\"derive\"] }",
    features: &[],
};
const ASYNC_GRAPHQL: Dep = Dep {
    name: "async-graphql",
    workspace_value: "{ version = \"7\", features = [\"dataloader\"] }",
    features: &[],
};
// `nest-rs-mcp`'s macros expand to bare `rmcp::` paths, so the user's manifest
// genuinely needs the crate — which makes this line a *contract* with what
// `nest-rs-mcp` itself compiled against. A different major puts two
// `ServerHandler` traits in one graph and every `#[tool_handler]` method
// mismatches. `rmcp_pin_matches_the_frameworks_own` pins the two together.
const RMCP: Dep = Dep {
    name: "rmcp",
    workspace_value: "{ version = \"2.2\", features = [\"server\", \"macros\", \"transport-streamable-http-server\"] }",
    features: &[],
};
// Every adapter skeleton that logs (`queue`, `schedule`, `ws`) writes a
// `tracing::` call in the handler body, and a workspace scaffolded by
// `nestrs new` carries no `tracing` in its features crate — the first generated
// adapter is usually the first code to reach for it.
const TRACING: Dep = Dep {
    name: "tracing",
    workspace_value: "\"0.1\"",
    features: &[],
};
const ANYHOW: Dep = Dep {
    name: "anyhow",
    workspace_value: "\"1\"",
    features: &[],
};
const SCHEMARS: Dep = Dep {
    name: "schemars",
    workspace_value: "{ version = \"1\", features = [\"uuid1\"] }",
    features: &[],
};
const CHRONO: Dep = Dep {
    name: "chrono",
    workspace_value: "{ version = \"0.4\", features = [\"serde\"] }",
    features: &[],
};
const SEA_ORM_MIGRATION: Dep = Dep {
    name: "sea-orm-migration",
    workspace_value: "{ version = \"2.0\", features = [\"sqlx-postgres\", \"runtime-tokio-rustls\"] }",
    features: &[],
};
const TRACING_SUBSCRIBER: Dep = Dep {
    name: "tracing-subscriber",
    workspace_value: "{ version = \"0.3\", features = [\"env-filter\"] }",
    features: &[],
};
const TOKIO: Dep = Dep {
    name: "tokio",
    workspace_value: "{ version = \"1\", features = [\"macros\", \"rt-multi-thread\"] }",
    features: &[],
};

/// The crates a resource port (DB-backed CRUD + HTTP) needs.
///
/// `schemars` and `nest-rs-authz` are call-site deps of the decorators, not of
/// the developer's own code: `#[expose]` derives `::schemars::JsonSchema` and
/// `#[crud]` emits `::nest_rs_authz::http::Authorize<…>` parameters. Omitting
/// either turns the very first `cargo check` after `g resource` into a wall of
/// macro-expansion errors.
pub fn resource_deps() -> Vec<&'static Dep> {
    vec![
        &SEAORM, &RESOURCE, &AUTHZ, &SEA_ORM, &SERDE, &UUID, &VALIDATOR, &SCHEMARS, &CHRONO,
    ]
}

/// The crates the authn/authz adapter (`g auth`) needs.
pub fn auth_deps() -> Vec<&'static Dep> {
    vec![&AUTHN, &AUTHZ, &SERDE, &UUID]
}

/// The crates the `migrations` + `seed` bootstrap crates need — the union of
/// what `templates::migration`'s two manifests declare `workspace = true`. A
/// name missing here is a generated crate whose own `Cargo.toml` names a
/// workspace dependency the root does not define, so keep the two in step.
/// (`async-trait` is deliberately absent: the migration template writes
/// `#[async_trait::async_trait]`, which `sea_orm_migration::prelude` re-exports
/// — the demo's migrations crate does not depend on it either.)
pub fn migrations_deps() -> Vec<&'static Dep> {
    vec![
        &SEAORM,
        &SEA_ORM,
        &SEA_ORM_MIGRATION,
        &ANYHOW,
        &TOKIO,
        &TRACING_SUBSCRIBER,
    ]
}

/// The crates an adapter for `transport` needs on top of the port.
pub fn adapter_deps(transport: Transport) -> Vec<&'static Dep> {
    match transport {
        Transport::Http => vec![],
        Transport::Graphql => vec![&GRAPHQL, &ASYNC_GRAPHQL, &GUARDS_GRAPHQL],
        // `serde` is not optional the moment a handler takes a typed payload —
        // the shape `/websockets/messages/` presents as the normal case, with
        // `#[derive(serde::Deserialize)]` on a DTO. `nest_rs_ws` re-exports only
        // `serde_json`, so without this the first typed handler fails to compile
        // and the page's install list was wrong by omission.
        Transport::Ws => vec![&WS, &GUARDS_WS, &SERDE, &TRACING],
        // `nest-rs-redis` carries `QueueConnection`/`QueueModule`/
        // `QueueWorkerModule`; `nest-rs-queue` is abstractions only. Without it
        // the very first `push_to` — the page the generator points at — fails
        // on `unresolved import nest_rs_redis`.
        Transport::Queue => vec![&QUEUE, &REDIS, &SERDE, &ANYHOW, &TRACING],
        Transport::Schedule => vec![&SCHEDULE, &ANYHOW, &TRACING],
        // `schemars` is a call-site dep of rmcp's `#[tool]` derive, which emits
        // bare `schemars::` paths — the crate has to be *linked*, so the
        // `nest_rs_mcp::schemars` re-export does not rescue a tool that takes
        // input (i.e. every non-trivial one). `GUARDS_MCP` turns on the
        // fallback operation guard so a global pool can gate `/mcp`.
        Transport::Mcp => vec![&MCP, &RMCP, &SCHEMARS, &GUARDS_MCP],
    }
}

/// What an **app crate** needs to depend on to name the transport's root
/// module — the one the generator's own printed next step tells the reader to
/// import. Empty where the scaffold already carries it: every app crate
/// `nestrs new` writes depends on `nest-rs-http`, so HTTP, WS and MCP add
/// nothing.
pub fn app_host_deps(transport: Transport) -> Vec<&'static Dep> {
    match transport {
        Transport::Http | Transport::Ws | Transport::Mcp => vec![],
        Transport::Graphql => vec![&GRAPHQL],
        Transport::Queue => vec![&REDIS],
        Transport::Schedule => vec![&SCHEDULE],
    }
}

/// The crates the GraphQL authz adapter (`authz/graphql/`) needs — the
/// per-operation bridge and the dataloader scope.
pub fn graphql_authz_deps() -> Vec<&'static Dep> {
    vec![&AUTHZ_GRAPHQL, &SEAORM_GRAPHQL, &GRAPHQL]
}

/// What exposing an entity over GraphQL needs: `#[expose(graphql)]` derives the
/// async-graphql object through `nest_rs_resource::graphql`, which that crate
/// only compiles under its own `graphql` feature.
pub fn graphql_port_deps() -> Vec<&'static Dep> {
    vec![&RESOURCE_GRAPHQL]
}

/// Edit the root manifest: add any missing `[workspace.dependencies]` entries.
pub fn ensure_workspace_deps(deps: Vec<&'static Dep>) -> Transform {
    Box::new(move |content: &str| {
        let mut doc = content.parse::<DocumentMut>().ok()?;
        let table = doc["workspace"]["dependencies"]
            .or_insert(toml_edit::table())
            .as_table_mut()?;
        let mut changed = false;
        for dep in &deps {
            if table.get(dep.name).is_none() {
                table.insert(dep.name, dep.workspace_item());
                changed = true;
            }
        }
        changed.then(|| doc.to_string())
    })
}

/// Edit the `features` manifest: add any missing `[dependencies]` entries as
/// `{ workspace = true, features = [...] }` — and, for an entry that is already
/// there, enable any feature it is missing. The second half is what a generator
/// bolting a transport onto a crate the starter manifest already depends on
/// needs: `nest-rs-guards` ships with every workspace, so `g graphql` can only
/// reach `nest_rs_guards::run_layered_graphql_chain` by turning its `graphql`
/// feature on.
pub fn ensure_features_deps(deps: Vec<&'static Dep>) -> Transform {
    Box::new(move |content: &str| {
        let mut doc = content.parse::<DocumentMut>().ok()?;
        let table = doc["dependencies"]
            .or_insert(toml_edit::table())
            .as_table_mut()?;
        let mut changed = false;
        for dep in &deps {
            if table.get(dep.name).is_none() {
                table.insert(dep.name, Item::Value(workspace_value()));
                changed = true;
            }
            let entry = table.get_mut(dep.name)?;
            changed |= enable_features(entry, dep.features);
        }
        changed.then(|| doc.to_string())
    })
}

/// Turn on every feature `wanted` that this dependency entry does not already
/// enable, in place. Handles the three shapes a manifest writes: the dotted
/// `dep.workspace = true`, the inline `dep = { workspace = true }`, and the
/// bare `dep = "1"` (widened to a table so the list has somewhere to live).
fn enable_features(entry: &mut Item, wanted: &[&str]) -> bool {
    if wanted.is_empty() {
        return false;
    }
    if let Some(version) = entry.as_str() {
        let mut table = toml_edit::InlineTable::new();
        table.insert("version", Value::from(version));
        *entry = Item::Value(Value::InlineTable(table));
    }
    let Some(table) = entry.as_table_like_mut() else {
        return false;
    };
    let mut features = table
        .get("features")
        .and_then(Item::as_array)
        .cloned()
        .unwrap_or_default();
    let mut changed = false;
    for feature in wanted {
        if !features.iter().any(|f| f.as_str() == Some(*feature)) {
            features.push(*feature);
            changed = true;
        }
    }
    if changed {
        table.insert("features", Item::Value(Value::Array(features)));
        // Re-space the entry we just widened, so the manifest the developer
        // inherits reads as if it had been written by hand.
        if let Some(inline) = entry.as_value_mut().and_then(Value::as_inline_table_mut) {
            inline.fmt();
        }
    }
    changed
}

fn parse_value(raw: &str) -> Item {
    format!("x = {raw}\n")
        .parse::<DocumentMut>()
        .ok()
        .and_then(|frag| frag.get("x").cloned())
        .unwrap_or_else(|| Item::Value(Value::from(raw)))
}

/// A bare `{ workspace = true }` entry — [`enable_features`] then adds whatever
/// the dependency needs on top, so the feature list is built in one place
/// whether the entry is new or already there.
fn workspace_value() -> Value {
    let mut table = toml_edit::InlineTable::new();
    table.insert("workspace", Value::from(true));
    Value::InlineTable(table)
}

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

    #[test]
    fn ensures_workspace_dep_idempotently() {
        let src = "[workspace.dependencies]\nnest-rs-core = \"0.1\"\n";
        let t = ensure_workspace_deps(vec![&SEAORM]);
        let out = t(src).expect("adds nest-rs-seaorm");
        // The pin tracks the CLI's own release line, not a hard-coded literal.
        assert!(out.contains(&format!("nest-rs-seaorm = \"{}\"", framework_req())));
        // already present → no-op
        assert!(ensure_workspace_deps(vec![&SEAORM])(&out).is_none());
    }

    #[test]
    fn ensures_features_dep_with_features() {
        let src = "[dependencies]\nnest-rs-core.workspace = true\n";
        let out = ensure_features_deps(vec![&SEAORM])(src).expect("adds dep");
        assert!(out.contains("nest-rs-seaorm"));
        assert!(out.contains("workspace = true"));
        assert!(out.contains("\"http\""));
    }

    // The `g graphql` case: the crate is already a dependency (every scaffolded
    // workspace carries `nest-rs-guards`), so only its feature is missing —
    // and without it `#[resolver]` expands to names that do not exist.
    #[test]
    fn enables_a_missing_feature_on_a_dependency_already_declared() {
        let src = "[dependencies]\nnest-rs-guards.workspace = true\n";
        let out = ensure_features_deps(vec![&GUARDS_GRAPHQL])(src).expect("enables graphql");
        assert!(out.contains("graphql"), "{out}");
        assert!(
            ensure_features_deps(vec![&GUARDS_GRAPHQL])(&out).is_none(),
            "a second run is a no-op: {out}",
        );
        let doc = out.parse::<DocumentMut>().expect("still valid TOML");
        assert_eq!(
            doc["dependencies"]["nest-rs-guards"]["workspace"].as_bool(),
            Some(true),
            "the existing keys survive: {out}",
        );
    }

    #[test]
    fn enabling_a_feature_keeps_the_ones_already_listed() {
        let src = "[dependencies]\nnest-rs-seaorm = { workspace = true, features = [\"http\"] }\n";
        let out = ensure_features_deps(vec![&SEAORM_GRAPHQL])(src).expect("adds graphql");
        assert!(
            out.contains("\"http\"") && out.contains("\"graphql\""),
            "{out}"
        );
    }

    /// Every crate a generated skeleton *names* has to be in that transport's
    /// dependency list. Derived from the template text rather than from a
    /// hand-kept list, so a skeleton that starts logging (or starts returning
    /// `anyhow::Result`) drags its dependency along on the same commit.
    ///
    /// The class this closes: three generators wrote `tracing::info!` into the
    /// handler body while adding only their own `nest-rs-*` crate, so the first
    /// `cargo check` after `nestrs g queue` was `cannot find module or crate
    /// `tracing``.
    #[test]
    fn a_skeleton_that_names_a_crate_declares_it() {
        // (token appearing in a skeleton, crate that must then be a dependency)
        const NAMED: &[(&str, &str)] = &[
            ("tracing::", "tracing"),
            ("anyhow::", "anyhow"),
            ("use anyhow", "anyhow"),
            ("serde::", "serde"),
            ("use serde", "serde"),
            ("async_graphql::", "async-graphql"),
            ("use async_graphql", "async-graphql"),
            ("rmcp::", "rmcp"),
        ];
        for transport in Transport::ALL {
            let declared: Vec<&str> = adapter_deps(transport).iter().map(|d| d.name).collect();
            for crud_port in [false, true] {
                let (handler, module) =
                    crate::commands::generate::adapter::templates_for(transport, crud_port);
                // The queue payload rides at the port, but `g queue` is what
                // writes it — so it counts against the same dependency list.
                let extra = if transport == Transport::Queue {
                    crate::templates::adapter::QUEUE_COMMAND
                } else {
                    ""
                };
                let src = format!("{handler}{module}{extra}");
                for (token, krate) in NAMED {
                    if src.contains(token) {
                        assert!(
                            declared.contains(krate),
                            "the {} skeleton writes `{token}` but `nestrs g {}` does not add \
                             `{krate}` — the first `cargo check` after generating fails",
                            transport.folder(),
                            transport.folder(),
                        );
                    }
                }
            }
        }
    }

    /// Render an adapter's handler the way the generator does — template plus
    /// the `crud_vars` that supply the differing handler.
    fn rendered_handler(transport: Transport, crud_port: bool) -> String {
        let names = crate::naming::Names::parse("posts");
        let (handler, _) = crate::commands::generate::adapter::templates_for(transport, crud_port);
        let mut r = crate::scaffold::Renderer::new(&names)
            .with("handler", names.handler_for(transport))
            .with("handler_mod", transport.handler_mod())
            .with("tmodule", names.module_for(transport));
        for (key, value) in crate::templates::crud_vars(crud_port, transport) {
            r = r.with(key, value);
        }
        r.render(handler)
    }

    /// A2: `count()` exists only on the `g feature` service. A `g resource`
    /// port's service is a `CrudService`, so a skeleton calling it produced a
    /// workspace that did not compile — and rustc blamed `Iterator::count`,
    /// sending the reader after an iterator bug. The CLI page's guarantee is
    /// unconditional ("a freshly-generated port plus **any** adapter compiles
    /// immediately"), so no CRUD skeleton may name it.
    #[test]
    fn no_crud_port_skeleton_calls_the_plain_features_count() {
        for transport in Transport::ALL {
            let rendered = rendered_handler(transport, true);
            assert!(
                !rendered.contains("svc.count()"),
                "the {} adapter renders `svc.count()` over a resource port, which a \
                 CrudService does not have:
{rendered}",
                transport.folder(),
            );
        }
    }

    /// …and the plain-feature skeletons must keep delegating, so the two
    /// variants cannot silently collapse into one inert stub.
    #[test]
    fn the_plain_feature_skeletons_still_delegate_to_the_port() {
        for transport in [Transport::Http, Transport::Graphql, Transport::Ws] {
            let rendered = rendered_handler(transport, false);
            assert!(
                rendered.contains("svc.count()"),
                "the {} adapter over a `g feature` port should still show the delegation:
\
                 {rendered}",
                transport.folder(),
            );
        }
    }

    /// `rmcp` is the one third-party crate a generated manifest must pin to the
    /// *same* major the framework compiled against: `#[tool_handler]` expands
    /// against `nest-rs-mcp`'s `ServerHandler` while the user's `impl` resolves
    /// against theirs, so two majors in one graph mismatch every method.
    ///
    /// Read from the workspace manifest rather than restated, so bumping the
    /// framework's `rmcp` fails here until the generator follows.
    #[test]
    fn the_rmcp_pin_matches_the_frameworks_own() {
        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../../Cargo.toml")
            .canonicalize()
            .expect("the framework workspace manifest");
        let doc = std::fs::read_to_string(&root)
            .expect("readable workspace manifest")
            .parse::<DocumentMut>()
            .expect("valid TOML");
        let ours = doc["workspace"]["dependencies"]["rmcp"].to_string();
        let ours = ours.trim();
        let generated = RMCP.workspace_value;
        assert_eq!(
            normalize(generated),
            normalize(ours),
            "`nestrs g mcp` writes {generated} while the framework builds against {ours} — \
             two rmcp majors in one graph make every `#[tool_handler]` method mismatch",
        );
    }

    /// Whitespace-insensitive compare of two inline-table literals.
    fn normalize(raw: &str) -> String {
        raw.chars().filter(|c| !c.is_whitespace()).collect()
    }

    // A hand-rolled manifest may pin a version literally; the feature list then
    // has nowhere to go until the entry is widened into a table.
    #[test]
    fn a_version_pinned_dependency_is_widened_to_carry_features() {
        let src = "[dependencies]\nnest-rs-guards = \"1.1\"\n";
        let out = ensure_features_deps(vec![&GUARDS_GRAPHQL])(src).expect("widens the entry");
        let doc = out.parse::<DocumentMut>().expect("still valid TOML");
        assert_eq!(
            doc["dependencies"]["nest-rs-guards"]["version"].as_str(),
            Some("1.1"),
            "the pin survives: {out}",
        );
        assert!(out.contains("graphql"), "{out}");
    }
}