pub fn render(template: &str, vars: &[(&str, &str)]) -> Result<String, String> {
let mut out = template.to_string();
for (k, v) in vars {
out = out.replace(&format!("{{{{{k}}}}}"), v);
}
if let Some(start) = out.find("{{") {
let tail: String = out[start..].chars().take(40).collect();
return Err(format!("unsubstituted template key near `{tail}`"));
}
Ok(out)
}
pub fn jerrycan_dep_spec(features: &[&str]) -> String {
let base = jerrycan_dep_spec_from(std::env::var("JERRYCAN_FRAMEWORK_DEP").ok());
inject_features(&base, features)
}
pub(crate) fn jerrycan_dep_spec_from(env: Option<String>) -> String {
env.unwrap_or_else(|| {
format!(
"jerrycan = {{ version = \"{}\", default-features = false }}",
env!("CARGO_PKG_VERSION")
)
})
}
pub(crate) fn inject_features(dep_line: &str, features: &[&str]) -> String {
if features.is_empty() {
return dep_line.to_string();
}
let list = features
.iter()
.map(|f| format!("\"{f}\""))
.collect::<Vec<_>>()
.join(", ");
match dep_line.rfind('}') {
Some(pos) => format!("{}, features = [{list}] }}", dep_line[..pos].trim_end()),
None => format!("{dep_line} # jerrycan: could not inject features = [{list}]"),
}
}
pub(crate) fn set_features(dep_line: &str, features: &[&str]) -> String {
inject_features(&strip_features(dep_line), features)
}
fn strip_features(dep_line: &str) -> String {
if let Some(pos) = dep_line.find(", features = [")
&& let Some(close) = dep_line.rfind('}')
&& pos < close
{
let head = dep_line[..pos].trim_end();
return format!("{head} }}");
}
dep_line.to_string()
}
pub const WORKSPACE_CARGO: &str = r#"[workspace]
resolver = "3"
members = [
"crates/app",
"crates/shared",
# jerrycan:members:begin (generated — do not edit between markers)
{{members}} # jerrycan:members:end
]
[workspace.package]
version = "0.1.0"
edition = "2024"
[workspace.dependencies]
{{jerrycan_dep}}
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time", "sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
"#;
pub const JERRYCAN_TOML: &str = r#"# jerrycan app configuration (layered: defaults < this file < JERRYCAN_* env)
name = "{{name}}"
"#;
pub const GITIGNORE: &str = "target/\n";
pub const APP_CARGO: &str = r#"[package]
name = "app"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
jerrycan.workspace = true
tokio.workspace = true
shared = { path = "../shared" }
# jerrycan:route-deps:begin (generated — do not edit between markers)
{{route_deps}}# jerrycan:route-deps:end
"#;
pub const SHARED_CARGO: &str = r#"[package]
name = "shared"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
serde.workspace = true
"#;
pub const SHARED_CARGO_AUTH: &str = r#"[package]
name = "shared"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
jerrycan.workspace = true
serde.workspace = true
"#;
pub const SHARED_LIB: &str = r#"//! Cross-module DTOs only — keep deliberately tiny (a jerrycan lint guards growth).
#![forbid(unsafe_code)]
"#;
pub const AUDIT_TOML: &str = r#"# Generated by jerrycan — cargo-audit policy for `jerrycan check` (db mode).
[advisories]
ignore = [
# rsa (transitive via sqlx-postgres): Marvin timing attack, no fix released.
"RUSTSEC-2023-0071",
]
"#;
pub const DENY_TOML: &str = r#"# Generated by jerrycan — dependency policy for `jerrycan check`.
[licenses]
allow = ["MIT", "Apache-2.0", "Unicode-3.0", "BSD-3-Clause", "ISC", "Zlib"]
# Generated workspace crates are unpublished (publish = false); don't license-check them.
private = { ignore = true }
[advisories]
yanked = "deny"
[bans]
[sources]
unknown-registry = "deny"
unknown-git = "deny"
"#;
pub const DENY_TOML_DB: &str = r#"# Generated by jerrycan — dependency policy for `jerrycan check`.
[licenses]
allow = ["MIT", "Apache-2.0", "Unicode-3.0", "BSD-3-Clause", "ISC", "Zlib", "CDLA-Permissive-2.0"]
# Generated workspace crates are unpublished (publish = false); don't license-check them.
private = { ignore = true }
[advisories]
yanked = "deny"
ignore = [
# proc-macro-error2 (transitive via sea-orm-macros -> sea-bae): the SeaORM
# derive macros pull it in at build time; it is unmaintained (RUSTSEC-2026-0173)
# with no patched release. It never reaches the runtime binary. Remove this when
# sea-orm drops it or a maintained fork ships.
"RUSTSEC-2026-0173",
]
[bans]
[sources]
unknown-registry = "deny"
unknown-git = "deny"
"#;
pub const ROUTE_CARGO: &str = r#"[package]
name = "route-{{name}}"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
jerrycan.workspace = true
serde.workspace = true
serde_json.workspace = true
shared = { path = "../../shared" }
[dev-dependencies]
tokio.workspace = true
serde_json.workspace = true
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_substitutes_all_keys_and_rejects_leftovers() {
let out = render(
"hi {{who}}, {{who}}! v{{n}}",
&[("who", "agent"), ("n", "0")],
)
.unwrap();
assert_eq!(out, "hi agent, agent! v0");
let err = render("oops {{missing}}", &[]).unwrap_err();
assert!(err.contains("missing"));
}
#[test]
fn default_dep_spec_tracks_the_crate_version() {
let default = jerrycan_dep_spec_from(None);
assert_eq!(
default,
format!(
"jerrycan = {{ version = \"{}\", default-features = false }}",
env!("CARGO_PKG_VERSION")
)
);
let local = jerrycan_dep_spec_from(Some(
"jerrycan = { path = \"/x\", default-features = false }".into(),
));
assert!(local.contains("path = \"/x\""));
}
#[test]
fn features_inject_into_the_dep_line() {
let line = "jerrycan = { version = \"0.1.0\", default-features = false }";
assert_eq!(
inject_features(line, &["db", "validate"]),
"jerrycan = { version = \"0.1.0\", default-features = false, features = [\"db\", \"validate\"] }"
);
assert_eq!(inject_features(line, &[]), line);
}
#[test]
fn workspace_template_has_member_markers() {
assert!(WORKSPACE_CARGO.contains("# jerrycan:members:begin"));
assert!(WORKSPACE_CARGO.contains("# jerrycan:members:end"));
assert!(APP_CARGO.contains("# jerrycan:route-deps:begin"));
}
}