use std::collections::BTreeMap;
use std::path::PathBuf;
use super::executor::{
plan_execution, ExecuteOptions, ExecutionError, ParsedModelsFile, ProjectView,
};
use super::planner::{ContextConfig, PlanRequest};
use super::review::{review_plan, RiskLevel};
use super::{generate_plan, AddField, FieldSpec, Plan, Primitive, RemoveField, RenameField};
use crate::ai::industry::industry_schema_for;
use crate::schema::{Schema, SchemaField, SchemaModel, SCHEMA_VERSION};
fn pkg_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
fn applicant_schema() -> Schema {
Schema {
version: SCHEMA_VERSION,
rustio_version: pkg_version(),
models: vec![SchemaModel {
name: "Applicant".into(),
table: "applicants".into(),
admin_name: "applicants".into(),
display_name: "Applicants".into(),
singular_name: "Applicant".into(),
fields: vec![
SchemaField {
name: "id".into(),
ty: "i64".into(),
nullable: false,
editable: false,
relation: None,
},
SchemaField {
name: "personnummer".into(),
ty: "String".into(),
nullable: false,
editable: true,
relation: None,
},
SchemaField {
name: "full_name".into(),
ty: "String".into(),
nullable: false,
editable: true,
relation: None,
},
],
relations: vec![],
core: false,
}],
}
}
fn patient_schema() -> Schema {
Schema {
version: SCHEMA_VERSION,
rustio_version: pkg_version(),
models: vec![SchemaModel {
name: "Patient".into(),
table: "patients".into(),
admin_name: "patients".into(),
display_name: "Patients".into(),
singular_name: "Patient".into(),
fields: vec![SchemaField {
name: "id".into(),
ty: "i64".into(),
nullable: false,
editable: false,
relation: None,
}],
relations: vec![],
core: false,
}],
}
}
const APPLICANT_MODELS_SRC: &str = r#"use chrono::{DateTime, Utc};
use rustio_core::{Error, Model, Row, RustioAdmin, Value};
#[derive(Debug, RustioAdmin)]
pub struct Applicant {
pub id: i64,
pub personnummer: String,
pub full_name: String,
}
impl Model for Applicant {
const TABLE: &'static str = "applicants";
const COLUMNS: &'static [&'static str] = &["id", "personnummer", "full_name"];
const INSERT_COLUMNS: &'static [&'static str] = &["personnummer", "full_name"];
fn id(&self) -> i64 { self.id }
fn from_row(row: Row<'_>) -> Result<Self, Error> {
Ok(Self {
id: row.get_i64("id")?,
personnummer: row.get_string("personnummer")?,
full_name: row.get_string("full_name")?,
})
}
fn insert_values(&self) -> Vec<Value> {
vec![
self.personnummer.clone().into(),
self.full_name.clone().into(),
]
}
}
"#;
fn applicant_project(root: &str) -> ProjectView {
let mut models_files = BTreeMap::new();
models_files.insert(
"applicants".to_string(),
ParsedModelsFile {
path: PathBuf::from(format!("{root}/apps/applicants/models.rs")),
source: APPLICANT_MODELS_SRC.to_string(),
struct_names: vec!["Applicant".into()],
},
);
ProjectView {
root: PathBuf::from(root),
models_files,
existing_migrations: vec!["0001_create_applicants.sql".into()],
migration_sources: BTreeMap::new(),
}
}
#[test]
fn se_country_infers_eu_region_and_requires_gdpr() {
let ctx = ContextConfig {
country: Some("SE".into()),
..Default::default()
};
assert_eq!(ctx.effective_region().as_deref(), Some("EU"));
assert!(ctx.requires_gdpr());
}
#[test]
fn us_country_does_not_infer_eu() {
let ctx = ContextConfig {
country: Some("US".into()),
..Default::default()
};
assert_eq!(ctx.effective_region(), None);
assert!(!ctx.requires_gdpr());
}
#[test]
fn explicit_gdpr_entry_wins_even_for_non_eu_country() {
let ctx = ContextConfig {
country: Some("US".into()),
compliance: vec!["GDPR".into()],
..Default::default()
};
assert!(ctx.requires_gdpr());
}
#[test]
fn pii_fields_are_country_and_gdpr_aware() {
let se = ContextConfig {
country: Some("SE".into()),
..Default::default()
};
let pii = se.pii_fields();
assert!(pii.contains(&"personnummer"));
assert!(pii.contains(&"email"));
assert!(pii.contains(&"phone"));
let no_ctx = ContextConfig::default();
assert!(no_ctx.pii_fields().is_empty());
}
#[test]
fn unknown_key_in_context_json_fails_loudly() {
let bad = r#"{ "countree": "SE" }"#;
assert!(ContextConfig::parse(bad).is_err());
}
#[test]
fn domain_key_from_old_format_is_rejected_after_0_6_0() {
let old = r#"{ "country": "SE", "domain": "housing" }"#;
assert!(ContextConfig::parse(old).is_err());
}
#[test]
fn same_prompt_produces_different_plan_with_se_context() {
let schema = Schema {
version: SCHEMA_VERSION,
rustio_version: pkg_version(),
models: vec![SchemaModel {
name: "Applicant".into(),
table: "applicants".into(),
admin_name: "applicants".into(),
display_name: "Applicants".into(),
singular_name: "Applicant".into(),
fields: vec![SchemaField {
name: "id".into(),
ty: "i64".into(),
nullable: false,
editable: false,
relation: None,
}],
relations: vec![],
core: false,
}],
};
let without = generate_plan(
&schema,
None,
PlanRequest::new("add personal_id to applicants"),
)
.unwrap();
match &without.plan.steps[0] {
Primitive::AddField(a) => assert_eq!(a.field.ty, "i32"),
_ => panic!("wrong primitive"),
}
let se = ContextConfig {
country: Some("SE".into()),
..Default::default()
};
let with = generate_plan(
&schema,
Some(&se),
PlanRequest::new("add personal_id to applicants"),
)
.unwrap();
match &with.plan.steps[0] {
Primitive::AddField(a) => {
assert_eq!(a.field.ty, "String");
assert!(!a.field.nullable);
}
_ => panic!("wrong primitive"),
}
assert!(
with.explanation.contains("YYYYMMDD"),
"SE explanation should mention the format: {}",
with.explanation,
);
}
#[test]
fn healthcare_patient_id_becomes_string_not_integer() {
let schema = patient_schema();
let ctx = ContextConfig {
industry: Some("healthcare".into()),
..Default::default()
};
let res = generate_plan(
&schema,
Some(&ctx),
PlanRequest::new("add patient_id to patients"),
)
.unwrap();
match &res.plan.steps[0] {
Primitive::AddField(a) => {
assert_eq!(a.field.ty, "String");
assert!(!a.field.nullable);
}
_ => panic!("wrong primitive"),
}
assert!(
res.explanation.contains("opaque"),
"explanation should say opaque: {}",
res.explanation,
);
}
#[test]
fn banking_account_number_becomes_string() {
let schema = Schema {
version: SCHEMA_VERSION,
rustio_version: pkg_version(),
models: vec![SchemaModel {
name: "Account".into(),
table: "accounts".into(),
admin_name: "accounts".into(),
display_name: "Accounts".into(),
singular_name: "Account".into(),
fields: vec![SchemaField {
name: "id".into(),
ty: "i64".into(),
nullable: false,
editable: false,
relation: None,
}],
relations: vec![],
core: false,
}],
};
let ctx = ContextConfig {
industry: Some("banking".into()),
..Default::default()
};
let res = generate_plan(
&schema,
Some(&ctx),
PlanRequest::new("add account_number to accounts"),
)
.unwrap();
match &res.plan.steps[0] {
Primitive::AddField(a) => assert_eq!(a.field.ty, "String"),
_ => panic!("wrong primitive"),
}
}
#[test]
fn removing_personnummer_in_se_context_is_critical() {
let schema = applicant_schema();
let plan = Plan::new(vec![Primitive::RemoveField(RemoveField {
model: "Applicant".into(),
field: "personnummer".into(),
})]);
let se = ContextConfig {
country: Some("SE".into()),
..Default::default()
};
let review = review_plan(&schema, &plan, Some(&se)).unwrap();
assert_eq!(review.risk, RiskLevel::Critical);
assert!(
review
.warnings
.iter()
.any(|w| w.contains("sensitive personal data")),
"warnings should cite GDPR/PII: {:?}",
review.warnings,
);
let review_no_ctx = review_plan(&schema, &plan, None).unwrap();
assert_eq!(review_no_ctx.risk, RiskLevel::High);
}
#[test]
fn renaming_personnummer_in_se_context_is_critical() {
let schema = applicant_schema();
let plan = Plan::new(vec![Primitive::RenameField(RenameField {
model: "Applicant".into(),
from: "personnummer".into(),
to: "pid".into(),
})]);
let se = ContextConfig {
country: Some("SE".into()),
..Default::default()
};
let review = review_plan(&schema, &plan, Some(&se)).unwrap();
assert_eq!(review.risk, RiskLevel::Critical);
}
#[test]
fn removing_industry_required_field_adds_warning() {
let schema = applicant_schema();
let plan = Plan::new(vec![Primitive::RemoveField(RemoveField {
model: "Applicant".into(),
field: "personnummer".into(),
})]);
let ctx = ContextConfig {
industry: Some("housing".into()),
..Default::default()
};
let review = review_plan(&schema, &plan, Some(&ctx)).unwrap();
assert!(
review
.warnings
.iter()
.any(|w| w.contains("standard convention for the `housing` industry")),
"warnings: {:?}",
review.warnings,
);
}
#[test]
fn executor_refuses_to_remove_personnummer_under_se() {
use super::review::PlanDocument;
use chrono::{TimeZone, Utc};
let schema = applicant_schema();
let project = applicant_project("/p");
let plan = Plan::new(vec![Primitive::RemoveField(RemoveField {
model: "Applicant".into(),
field: "personnummer".into(),
})]);
let doc = PlanDocument {
version: super::review::PLAN_DOCUMENT_VERSION,
created_at: Utc
.with_ymd_and_hms(2026, 1, 1, 0, 0, 0)
.single()
.unwrap()
.to_rfc3339(),
prompt: "".into(),
explanation: "".into(),
risk: RiskLevel::High,
impact: Default::default(),
plan,
};
let se = ContextConfig {
country: Some("SE".into()),
..Default::default()
};
let err = plan_execution(
&schema,
&project,
&doc,
&ExecuteOptions::default(),
Some(&se),
)
.expect_err("executor must refuse removing a PII field");
assert!(
matches!(
err,
ExecutionError::PolicyViolation { .. } | ExecutionError::CriticalRiskNotAllowed
),
"unexpected error: {err:?}",
);
let plan_rename = Plan::new(vec![Primitive::RenameField(RenameField {
model: "Applicant".into(),
from: "personnummer".into(),
to: "pid".into(),
})]);
let doc_rename = PlanDocument {
plan: plan_rename,
..doc.clone()
};
let err2 = plan_execution(
&schema,
&project,
&doc_rename,
&ExecuteOptions::default(),
Some(&se),
)
.expect_err("executor must refuse renaming a PII field");
assert!(
matches!(
err2,
ExecutionError::PolicyViolation { .. } | ExecutionError::CriticalRiskNotAllowed
),
"unexpected error: {err2:?}",
);
}
#[test]
fn executor_allows_non_pii_changes_under_se() {
use super::planner::PlanResult;
use super::review::build_plan_document_with_timestamp;
use chrono::{TimeZone, Utc};
let schema = applicant_schema();
let project = applicant_project("/p");
let plan = Plan::new(vec![Primitive::AddField(AddField {
model: "Applicant".into(),
field: FieldSpec {
name: "notes".into(),
ty: "String".into(),
nullable: true,
editable: true,
},
})]);
let se = ContextConfig {
country: Some("SE".into()),
..Default::default()
};
let doc = build_plan_document_with_timestamp(
&schema,
"add notes",
&PlanResult {
plan,
explanation: "".into(),
},
Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap(),
Some(&se),
)
.unwrap();
let preview = plan_execution(
&schema,
&project,
&doc,
&ExecuteOptions::default(),
Some(&se),
)
.unwrap();
assert_eq!(preview.applied_steps, 1);
}
#[test]
fn industry_registry_has_housing_healthcare_and_banking() {
for name in ["housing", "healthcare", "banking"] {
let schema = industry_schema_for(name)
.unwrap_or_else(|| panic!("industry `{name}` should be in the registry"));
assert!(!schema.required_fields.is_empty());
assert!(!schema.conventions.is_empty());
}
assert!(industry_schema_for("unknown_industry_xyz").is_none());
}
#[test]
fn none_context_reproduces_0_5_x_behaviour() {
let schema = applicant_schema();
let plan = Plan::new(vec![Primitive::AddField(AddField {
model: "Applicant".into(),
field: FieldSpec {
name: "phone".into(),
ty: "String".into(),
nullable: true,
editable: true,
},
})]);
let review = review_plan(&schema, &plan, None).unwrap();
assert_eq!(review.risk, RiskLevel::Low);
assert!(
review.warnings.is_empty(),
"no-context add should have no warnings: {:?}",
review.warnings,
);
}