use toml_edit::{DocumentMut, Item, Value};
use crate::naming::Transport;
use crate::scaffold::Transform;
use crate::version::framework_req;
pub(crate) struct Dep {
name: &'static str,
workspace_value: &'static str,
features: &'static [&'static str],
}
impl Dep {
fn workspace_item(&self) -> Item {
if self.name.starts_with("nest-rs-") {
parse_value(&format!("\"{}\"", framework_req()))
} else {
parse_value(self.workspace_value)
}
}
}
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: &[],
};
const REDIS: Dep = Dep {
name: "nest-rs-redis",
workspace_value: "",
features: &[],
};
const MCP: Dep = Dep {
name: "nest-rs-mcp",
workspace_value: "",
features: &[],
};
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"],
};
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"],
};
const GUARDS_WS: Dep = Dep {
name: "nest-rs-guards",
workspace_value: "",
features: &["ws"],
};
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: &[],
};
const RMCP: Dep = Dep {
name: "rmcp",
workspace_value: "{ version = \"2.2\", features = [\"server\", \"macros\", \"transport-streamable-http-server\"] }",
features: &[],
};
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: &[],
};
pub fn resource_deps() -> Vec<&'static Dep> {
vec![
&SEAORM, &RESOURCE, &AUTHZ, &SEA_ORM, &SERDE, &UUID, &VALIDATOR, &SCHEMARS, &CHRONO,
]
}
pub fn auth_deps() -> Vec<&'static Dep> {
vec![&AUTHN, &AUTHZ, &SERDE, &UUID]
}
pub fn migrations_deps() -> Vec<&'static Dep> {
vec![
&SEAORM,
&SEA_ORM,
&SEA_ORM_MIGRATION,
&ANYHOW,
&TOKIO,
&TRACING_SUBSCRIBER,
]
}
pub fn adapter_deps(transport: Transport) -> Vec<&'static Dep> {
match transport {
Transport::Http => vec![],
Transport::Graphql => vec![&GRAPHQL, &ASYNC_GRAPHQL, &GUARDS_GRAPHQL],
Transport::Ws => vec![&WS, &GUARDS_WS, &SERDE, &TRACING],
Transport::Queue => vec![&QUEUE, &REDIS, &SERDE, &ANYHOW, &TRACING],
Transport::Schedule => vec![&SCHEDULE, &ANYHOW, &TRACING],
Transport::Mcp => vec![&MCP, &RMCP, &SCHEMARS, &GUARDS_MCP],
}
}
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],
}
}
pub fn graphql_authz_deps() -> Vec<&'static Dep> {
vec![&AUTHZ_GRAPHQL, &SEAORM_GRAPHQL, &GRAPHQL]
}
pub fn graphql_port_deps() -> Vec<&'static Dep> {
vec![&RESOURCE_GRAPHQL]
}
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())
})
}
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())
})
}
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)));
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)))
}
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");
assert!(out.contains(&format!("nest-rs-seaorm = \"{}\"", framework_req())));
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\""));
}
#[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}"
);
}
#[test]
fn a_skeleton_that_names_a_crate_declares_it() {
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);
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(),
);
}
}
}
}
}
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)
}
#[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(),
);
}
}
#[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(),
);
}
}
#[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",
);
}
fn normalize(raw: &str) -> String {
raw.chars().filter(|c| !c.is_whitespace()).collect()
}
#[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}");
}
}