use std::path::Path;
use super::emit::{Plan, Revert};
use super::model::{model_file_exists, validate_resource_name};
use super::naming::{pascal, pluralize, snake};
use super::schema_edit::{add_mod_declaration, add_policy_registration_to_app, update_main_rs};
use super::{GenerateError, ensure_project_root, read_or_empty};
pub fn plan_policy(
project_root: &Path,
name: &str,
for_destroy: bool,
) -> Result<Plan, GenerateError> {
ensure_project_root(project_root)?;
validate_resource_name(name)?;
let snake_name = snake(name);
let pascal_name = pascal(name);
let plural = pluralize(&snake_name);
if !for_destroy && !model_file_exists(project_root, &plural, &snake_name) {
return Err(GenerateError::Config(format!(
"no model '{pascal_name}' found — run `autumn generate model {pascal_name}` first \
(expected src/models/{snake_name}.rs)"
)));
}
let owner = detect_owner_column_from_model(project_root, &snake_name);
let model_path = model_type_path(project_root, &snake_name, &pascal_name);
let mut plan = Plan::new(project_root);
push_policy_files(
&mut plan,
project_root,
&pascal_name,
&snake_name,
&plural,
owner.as_ref(),
&model_path,
);
let main_path = project_root.join("src").join("main.rs");
let main_existing = std::fs::read_to_string(&main_path).map_err(|e| {
GenerateError::Io(std::io::Error::new(
e.kind(),
format!("{}: {e}", main_path.display()),
))
})?;
let updated_main =
wire_policy_into_main(&main_existing, &pascal_name, &snake_name, &model_path);
plan.modify(main_path.clone(), updated_main);
plan.push_revert(Revert::PolicyRegistration {
path: main_path,
pascal: pascal_name,
snake: snake_name,
});
Ok(plan)
}
pub(super) fn push_policy_files(
plan: &mut Plan,
project_root: &Path,
pascal_name: &str,
snake_name: &str,
plural: &str,
owner: Option<&OwnerColumn>,
model_path: &str,
) {
plan.create(
project_root
.join("src")
.join("policies")
.join(format!("{snake_name}.rs")),
render_policy_file(pascal_name, snake_name, plural, owner, model_path),
);
let mod_path = project_root.join("src").join("policies").join("mod.rs");
plan.modify(
mod_path.clone(),
add_mod_declaration(&read_or_empty(&mod_path), snake_name),
);
plan.push_revert(Revert::ModDecl {
path: mod_path,
name: snake_name.to_owned(),
});
}
pub(super) fn wire_policy_into_main(
main_src: &str,
pascal_name: &str,
snake_name: &str,
model_path: &str,
) -> String {
let with_mod = update_main_rs(main_src, &["policies"], &[]);
add_policy_registration_to_app(&with_mod, model_path, pascal_name, snake_name)
}
#[derive(Debug, Clone)]
pub(super) struct OwnerColumn {
pub name: String,
pub nullable: bool,
}
fn model_type_path(project_root: &Path, snake_name: &str, pascal_name: &str) -> String {
let per_resource = project_root
.join("src")
.join("models")
.join(format!("{snake_name}.rs"));
let single_file = project_root.join("src").join("models.rs");
if !per_resource.exists() && single_file.exists() {
format!("crate::models::{pascal_name}")
} else {
format!("crate::models::{snake_name}::{pascal_name}")
}
}
const OWNER_COLUMN_CANDIDATES: &[&str] = &["user_id", "author_id", "owner_id"];
pub(super) fn detect_owner_column(
columns: &[String],
user_ref_columns: &[String],
) -> Option<String> {
for candidate in OWNER_COLUMN_CANDIDATES {
if columns.iter().any(|c| c == candidate) {
return Some((*candidate).to_owned());
}
}
user_ref_columns.first().cloned()
}
fn detect_owner_column_from_model(project_root: &Path, snake_name: &str) -> Option<OwnerColumn> {
let fields = model_fields(project_root, snake_name);
let columns: Vec<String> = fields.iter().map(|(name, _)| name.clone()).collect();
let name = detect_owner_column(&columns, &[])?;
let nullable = fields
.iter()
.find(|(field_name, _)| *field_name == name)
.is_some_and(|(_, nullable)| *nullable);
Some(OwnerColumn { name, nullable })
}
fn model_fields(project_root: &Path, snake_name: &str) -> Vec<(String, bool)> {
let pascal_name = pascal(snake_name);
let per_resource = project_root
.join("src")
.join("models")
.join(format!("{snake_name}.rs"));
let content = if per_resource.exists() {
read_or_empty(&per_resource)
} else {
let single_file = project_root.join("src").join("models.rs");
if !single_file.exists() {
return Vec::new();
}
read_or_empty(&single_file)
};
let Ok(file) = syn::parse_file(&content) else {
return Vec::new();
};
let Some(item_struct) = file.items.iter().find_map(|item| match item {
syn::Item::Struct(s) if s.ident == pascal_name => Some(s),
_ => None,
}) else {
return Vec::new();
};
let syn::Fields::Named(fields) = &item_struct.fields else {
return Vec::new();
};
fields
.named
.iter()
.filter_map(|f| {
f.ident
.as_ref()
.map(|ident| (ident.to_string(), type_is_option(&f.ty)))
})
.collect()
}
fn type_is_option(ty: &syn::Type) -> bool {
let syn::Type::Path(type_path) = ty else {
return false;
};
type_path
.path
.segments
.last()
.is_some_and(|seg| seg.ident == "Option")
}
#[allow(clippy::option_if_let_else)] fn render_policy_file(
pascal_name: &str,
snake_name: &str,
plural: &str,
owner: Option<&OwnerColumn>,
model_path: &str,
) -> String {
let module_doc = if owner.is_some() {
format!(
"//! Generated by `autumn generate policy`.\n\
//!\n\
//! Record-level authorization for [`{pascal_name}`]. Edit freely — once\n\
//! generated, this is ordinary user code.\n"
)
} else {
format!(
"//! Generated by `autumn generate policy`.\n\
//!\n\
//! Record-level authorization for [`{pascal_name}`]. Edit freely — once\n\
//! generated, this is ordinary user code.\n\
//!\n\
//! No owner column (`user_id`/`author_id`/`owner_id`) was detected on this\n\
//! model, so `can_update`/`can_delete` authorize any authenticated user and\n\
//! the scope default-denies. This is a placeholder — replace the authenticated\n\
//! check with a real per-record ownership rule where the `SECURITY TODO`/`TODO`\n\
//! markers are before production.\n"
)
};
let (update_delete_bodies, scope_body, scope_ctx_param) = match owner {
Some(owner) => {
let col = &owner.name;
let owner_match = if owner.nullable {
format!("ctx.user_id_i64() == {snake_name}.{col}")
} else {
format!("ctx.user_id_i64() == Some({snake_name}.{col})")
};
(
format!(
" fn can_update<'a>(&'a self, ctx: &'a PolicyContext, {snake_name}: &'a {pascal_name}) -> BoxFuture<'a, bool> {{\n \
Box::pin(async move {{ ctx.has_role(\"admin\") || {owner_match} }})\n \
}}\n\n \
fn can_delete<'a>(&'a self, ctx: &'a PolicyContext, {snake_name}: &'a {pascal_name}) -> BoxFuture<'a, bool> {{\n \
Box::pin(async move {{ ctx.has_role(\"admin\") || {owner_match} }})\n \
}}\n"
),
format!(
" Box::pin(async move {{\n \
let owner_id = ctx.user_id_i64().unwrap_or(-1);\n \
Ok({plural}::table\n \
.filter({plural}::{col}.eq(owner_id))\n \
.select({pascal_name}::as_select())\n \
.load(conn)\n \
.await?)\n \
}})\n"
),
"ctx",
)
}
None => (
format!(
" fn can_update<'a>(&'a self, ctx: &'a PolicyContext, _{snake_name}: &'a {pascal_name}) -> BoxFuture<'a, bool> {{\n \
// SECURITY TODO: this only checks authentication — replace with a real\n \
// per-record ownership rule before production.\n \
Box::pin(async move {{ ctx.is_authenticated() }})\n \
}}\n\n \
fn can_delete<'a>(&'a self, ctx: &'a PolicyContext, _{snake_name}: &'a {pascal_name}) -> BoxFuture<'a, bool> {{\n \
// SECURITY TODO: this only checks authentication — replace with a real\n \
// per-record ownership rule before production.\n \
Box::pin(async move {{ ctx.is_authenticated() }})\n \
}}\n"
),
" // TODO: no owner column detected — implement the scope filter.\n \
Box::pin(async { Ok(Vec::new()) })\n"
.to_owned(),
"_ctx",
),
};
let scope_conn_param = if owner.is_some() { "conn" } else { "_conn" };
let diesel_imports = if owner.is_some() {
"use diesel::prelude::*;\nuse diesel_async::RunQueryDsl;\n"
} else {
""
};
let schema_import = if owner.is_some() {
format!("use crate::schema::{plural};\n")
} else {
String::new()
};
format!(
"{module_doc}\
use autumn_web::authorization::{{BoxFuture, Policy, PolicyContext, Scope}};
{diesel_imports}\
use {model_path};
{schema_import}
#[derive(Default)]
pub struct {pascal_name}Policy;
impl Policy<{pascal_name}> for {pascal_name}Policy {{
fn can_show<'a>(&'a self, _ctx: &'a PolicyContext, _{snake_name}: &'a {pascal_name}) -> BoxFuture<'a, bool> {{
// Reads are public by default. Tighten this if shows should be gated.
Box::pin(async {{ true }})
}}
fn can_create<'a>(&'a self, ctx: &'a PolicyContext) -> BoxFuture<'a, bool> {{
// Any authenticated user may create.
Box::pin(async move {{ ctx.is_authenticated() }})
}}
{update_delete_bodies}}}
#[derive(Default)]
pub struct {pascal_name}Scope;
impl Scope<{pascal_name}> for {pascal_name}Scope {{
fn list<'a>(
&'a self,
{scope_ctx_param}: &'a PolicyContext,
{scope_conn_param}: &'a mut autumn_web::RuntimeConnection,
) -> BoxFuture<'a, autumn_web::AutumnResult<Vec<{pascal_name}>>> {{
{scope_body} }}
}}
"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::Flags;
use std::fs;
use tempfile::TempDir;
fn default_main() -> &'static str {
r#"use autumn_web::prelude::*;
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.run()
.await;
}
"#
}
fn project_with_model(fields: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("src/models")).unwrap();
fs::write(tmp.path().join("src/main.rs"), default_main()).unwrap();
fs::write(
tmp.path().join("src/models/post.rs"),
format!(
"use autumn_web::prelude::*;\n\n#[model]\npub struct Post {{\n #[id]\n pub id: i64,\n{fields}}}\n"
),
)
.unwrap();
tmp
}
fn project_with_single_file_model(fields: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/main.rs"), default_main()).unwrap();
fs::write(
tmp.path().join("src/models.rs"),
format!(
"use autumn_web::prelude::*;\nuse crate::schema::posts;\n\n#[model]\npub struct Post {{\n #[id]\n pub id: i64,\n{fields}}}\n"
),
)
.unwrap();
tmp
}
#[test]
fn model_missing_returns_named_error_and_writes_nothing() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/main.rs"), default_main()).unwrap();
let err = plan_policy(tmp.path(), "Post", false).unwrap_err();
match err {
GenerateError::Config(msg) => {
assert!(msg.contains("no model 'Post' found"), "{msg}");
assert!(msg.contains("src/models/post.rs"), "{msg}");
}
other => panic!("expected Config error, got {other:?}"),
}
assert!(!tmp.path().join("src/policies").exists());
}
#[test]
fn emits_policy_and_scope_structs() {
let tmp = project_with_model(" pub user_id: i64,\n pub title: String,\n");
plan_policy(tmp.path(), "Post", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let src = fs::read_to_string(tmp.path().join("src/policies/post.rs")).unwrap();
assert!(src.contains("pub struct PostPolicy"), "{src}");
assert!(src.contains("impl Policy<Post> for PostPolicy"), "{src}");
assert!(src.contains("pub struct PostScope"), "{src}");
assert!(src.contains("impl Scope<Post> for PostScope"), "{src}");
assert!(src.contains("#[derive(Default)]"), "{src}");
}
#[test]
fn user_id_owner_generates_ownership_check() {
let tmp = project_with_model(" pub user_id: i64,\n pub title: String,\n");
plan_policy(tmp.path(), "Post", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let src = fs::read_to_string(tmp.path().join("src/policies/post.rs")).unwrap();
assert!(
src.contains("ctx.has_role(\"admin\") || ctx.user_id_i64() == Some(post.user_id)"),
"{src}"
);
assert!(src.contains("posts::user_id.eq(owner_id)"), "{src}");
assert!(
!src.contains("TODO"),
"owner case must not carry TODO: {src}"
);
}
#[test]
fn author_id_owner_generates_ownership_check() {
let tmp = project_with_model(" pub author_id: i64,\n pub body: String,\n");
plan_policy(tmp.path(), "Post", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let src = fs::read_to_string(tmp.path().join("src/policies/post.rs")).unwrap();
assert!(src.contains("Some(post.author_id)"), "{src}");
}
#[test]
fn nullable_owner_compares_option_to_option_without_some_wrap() {
let tmp = project_with_model(" pub author_id: Option<i64>,\n pub body: String,\n");
plan_policy(tmp.path(), "Post", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let src = fs::read_to_string(tmp.path().join("src/policies/post.rs")).unwrap();
assert!(
src.contains("ctx.has_role(\"admin\") || ctx.user_id_i64() == post.author_id"),
"nullable owner must compare option-to-option: {src}"
);
assert!(
!src.contains("Some(post.author_id)"),
"nullable owner must not wrap in Some(...): {src}"
);
assert!(src.contains("posts::author_id.eq(owner_id)"), "{src}");
}
#[test]
fn non_nullable_owner_keeps_some_wrap() {
let tmp = project_with_model(" pub user_id: i64,\n");
plan_policy(tmp.path(), "Post", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let src = fs::read_to_string(tmp.path().join("src/policies/post.rs")).unwrap();
assert!(
src.contains("ctx.user_id_i64() == Some(post.user_id)"),
"non-nullable owner must keep the Some(...) wrap: {src}"
);
}
#[test]
fn no_owner_column_authorizes_authenticated_with_security_todo() {
let tmp = project_with_model(" pub title: String,\n pub body: String,\n");
plan_policy(tmp.path(), "Post", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let src = fs::read_to_string(tmp.path().join("src/policies/post.rs")).unwrap();
assert!(
src.contains("// SECURITY TODO: this only checks authentication"),
"must carry the SECURITY TODO marker: {src}"
);
assert_eq!(
src.matches("Box::pin(async move { ctx.is_authenticated() })")
.count(),
3,
"no-owner can_update/can_delete must authenticate-check like can_create: {src}"
);
assert!(
!src.contains("Box::pin(async { false })"),
"no-owner policy must no longer default-deny: {src}"
);
assert!(
src.contains("// TODO: no owner column detected — implement the scope filter."),
"scope must keep its TODO marker: {src}"
);
assert!(
src.contains("//! No owner column"),
"module doc must note the missing owner column: {src}"
);
}
#[test]
fn adds_mod_entry_and_builder_registration() {
let tmp = project_with_model(" pub user_id: i64,\n");
plan_policy(tmp.path(), "Post", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let mod_rs = fs::read_to_string(tmp.path().join("src/policies/mod.rs")).unwrap();
assert!(mod_rs.contains("pub mod post;"), "{mod_rs}");
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(main.contains("mod policies;"), "{main}");
assert!(
main.contains(
".policy::<crate::models::post::Post, _>(crate::policies::post::PostPolicy)"
),
"{main}"
);
assert!(
main.contains(
".scope::<crate::models::post::Post, _>(crate::policies::post::PostScope)"
),
"{main}"
);
}
#[test]
fn single_file_models_layout_imports_and_registers_crate_models_pascal() {
let tmp = project_with_single_file_model(" pub user_id: i64,\n");
plan_policy(tmp.path(), "Post", false)
.unwrap()
.execute(Flags::default())
.unwrap();
let policy = fs::read_to_string(tmp.path().join("src/policies/post.rs")).unwrap();
assert!(
policy.contains("use crate::models::Post;"),
"single-file layout must import crate::models::Post: {policy}"
);
assert!(
!policy.contains("crate::models::post::"),
"single-file layout must not reference crate::models::post: {policy}"
);
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(
main.contains(".policy::<crate::models::Post, _>(crate::policies::post::PostPolicy)"),
"single-file layout policy registration must use crate::models::Post: {main}"
);
assert!(
main.contains(".scope::<crate::models::Post, _>(crate::policies::post::PostScope)"),
"single-file layout scope registration must use crate::models::Post: {main}"
);
}
#[test]
fn builder_registration_inserted_exactly_once_when_run_twice() {
let tmp = project_with_model(" pub user_id: i64,\n");
plan_policy(tmp.path(), "Post", false)
.unwrap()
.execute(Flags::default())
.unwrap();
plan_policy(tmp.path(), "Post", false)
.unwrap()
.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert_eq!(
main.matches(".policy::<crate::models::post::Post, _>")
.count(),
1,
"policy registration must appear exactly once: {main}"
);
assert_eq!(
main.matches(".scope::<crate::models::post::Post, _>")
.count(),
1,
"scope registration must appear exactly once: {main}"
);
let mod_rs = fs::read_to_string(tmp.path().join("src/policies/mod.rs")).unwrap();
assert_eq!(mod_rs.matches("pub mod post;").count(), 1, "{mod_rs}");
}
#[test]
fn generate_then_destroy_round_trips_main_rs() {
let tmp = project_with_model(" pub user_id: i64,\n");
let main_path = tmp.path().join("src/main.rs");
let original_main = fs::read_to_string(&main_path).unwrap();
plan_policy(tmp.path(), "Post", false)
.unwrap()
.execute(Flags::default())
.unwrap();
plan_policy(tmp.path(), "Post", false)
.unwrap()
.revert(Flags::default())
.unwrap();
assert!(!tmp.path().join("src/policies/post.rs").exists());
assert!(!tmp.path().join("src/policies").exists());
assert_eq!(fs::read_to_string(&main_path).unwrap(), original_main);
}
#[test]
fn destroy_succeeds_after_model_already_deleted() {
let tmp = project_with_model(" pub user_id: i64,\n");
let main_path = tmp.path().join("src/main.rs");
let original_main = fs::read_to_string(&main_path).unwrap();
plan_policy(tmp.path(), "Post", false)
.unwrap()
.execute(Flags::default())
.unwrap();
fs::remove_file(tmp.path().join("src/models/post.rs")).unwrap();
assert!(plan_policy(tmp.path(), "Post", false).is_err());
let plan =
plan_policy(tmp.path(), "Post", true).expect("destroy plan builds without the model");
plan.revert(Flags {
dry_run: false,
force: true,
})
.unwrap();
assert!(!tmp.path().join("src/policies/post.rs").exists());
assert!(!tmp.path().join("src/policies").exists());
let main = fs::read_to_string(&main_path).unwrap();
assert!(
!main.contains(".policy::<") && !main.contains(".scope::<"),
"destroy must remove the policy/scope registrations: {main}"
);
assert!(!main.contains("mod policies;"), "{main}");
assert_eq!(main, original_main);
}
#[test]
fn detect_owner_column_priority() {
assert_eq!(
detect_owner_column(&["author_id".into(), "user_id".into()], &[]),
Some("user_id".to_owned())
);
assert_eq!(
detect_owner_column(&["author_id".into(), "owner_id".into()], &[]),
Some("author_id".to_owned())
);
assert_eq!(
detect_owner_column(&["owner_id".into()], &[]),
Some("owner_id".to_owned())
);
assert_eq!(
detect_owner_column(&["title".into()], &["team_id".into()]),
Some("team_id".to_owned())
);
assert_eq!(detect_owner_column(&["title".into()], &[]), None);
}
#[test]
fn plan_errors_when_not_in_project() {
let tmp = TempDir::new().unwrap();
let err = plan_policy(tmp.path(), "Post", false).unwrap_err();
assert!(matches!(err, GenerateError::NotInProject));
}
}