#[path = "support/temp.rs"] mod temp; use std::fs; use dever_core::diagnostic::Diagnostic; use dever_core::source::SourceMap; use temp::TemporaryDirectory; fn sources(files: &[(&str, &str)]) -> SourceMap { let mut sources = SourceMap::default(); for (path, text) in files { sources.add(*path, *text); } sources } fn checked(files: &[(&str, &str)]) -> dever_core::hir::Program { let sources = sources(files); dever_core::check(&sources).unwrap_or_else(|errors| { panic!( "{}", errors .iter() .map(|error| error.render(&sources)) .collect::>() .join("\n") ) }) } fn rejected(files: &[(&str, &str)], code: &str, message: &str) -> Vec { let sources = sources(files); let errors = dever_core::check(&sources).expect_err("source must be rejected"); assert!( errors .iter() .any(|error| error.code == code && error.message.contains(message)), "expected {code}: {message}; got {errors:?}" ); errors } #[test] fn app_role_exports_domain_capabilities_without_exposing_topic_files() { let program = checked(&[ ( "main.dever", "main() (response: user.account.Greeting) { response = user.account.greet() }", ), ( "user/account/app/commands.dever", "type Greeting { message: Text }\ngreet() (response: Greeting) { response = Greeting { message = int.to_text(count()) } }", ), ( "user/account/app/queries.dever", "count() (response: Int) { response = 1 }", ), ]); let snapshot = program.api_snapshot(); assert!(snapshot.contains("function user.account.greet")); assert!(snapshot.contains("function user.account.count")); assert!(snapshot.contains("type user.account.Greeting")); assert!(!snapshot.contains("commands")); assert!(!snapshot.contains("queries")); let warnings = program.application_warnings("main.main").unwrap(); assert!(warnings.iter().any(|warning| { warning.code == "W007" && warning.message.contains("user.account.count") })); } #[test] fn source_roles_replace_public_and_enforce_cohesive_layouts() { rejected( &[( "user/account/app.dever", "public greet() (response: Text) { response = \"hello\" }", )], "P001", "source role", ); rejected( &[( "user/account/app/commands.dever", "greet() (response: Text) { response = \"hello\" }", )], "C003", "at least two cohesive topic files", ); rejected( &[ ( "user/account/app.dever", "greet() (response: Text) { response = \"hello\" }", ), ( "user/account/app/queries.dever", "count() (response: Int) { response = 1 }", ), ], "C003", "mutually exclusive", ); rejected( &[( "user/common/app.dever", "greet() (response: Text) { response = \"hello\" }", )], "C003", "generic source bucket", ); } #[test] fn model_root_and_topic_keep_independent_persisted_identities() { let root = ("user/account/model.dever", "type Account { name: Text }"); let topic = ( "user/account/model/session.dever", "type Session { token: Text }", ); let root_schema = checked(&[root]).model_snapshot(); let topic_schema = checked(&[topic]).model_snapshot(); let combined_schema = checked(&[root, topic]).model_snapshot(); assert!(root_schema.contains("model user.account.model name=Account table=account ")); assert!(topic_schema.contains("model user.account.model.session name=Session table=session ")); assert_eq!( combined_schema, format!( "{root_schema}{}", topic_schema.strip_prefix("dever-model 1\n").unwrap() ) ); } #[test] fn api_paths_allow_one_topic_with_or_without_a_root_file() { // Empty declarations isolate layout checks from API declaration syntax. let root = ("user/account/api.dever", ""); let topic = ("user/account/api/session/history.dever", ""); checked(&[topic]); checked(&[root, topic]); } #[test] fn business_role_topics_still_require_cohesive_exclusive_directories() { for role in ["app", "domain", "port", "adapter", "job"] { let root = format!("user/account/{role}.dever"); let topic = format!("user/account/{role}/registration.dever"); rejected(&[(&topic, "")], "C003", "at least two cohesive topic files"); let errors = rejected(&[(&root, ""), (&topic, "")], "C003", "mutually exclusive"); let conflict = errors .iter() .find(|error| error.message.contains("mutually exclusive")) .unwrap(); assert_eq!(conflict.related.len(), 1); assert_eq!(conflict.related[0].message, "role file declared here"); } } #[test] fn model_and_api_exceptions_preserve_topic_name_and_depth_checks() { rejected( &[("user/account/model/session/history.dever", "")], "C003", "topic directories are flat", ); for role in ["model", "api"] { let topic = format!("user/account/{role}/helpers.dever"); rejected(&[(&topic, "")], "C003", "generic source bucket"); } } #[test] fn strict_project_loading_rejects_the_removed_service_layout() { checked(&[ ("main.dever", "main() () {}"), ( "user/service/greeting.dever", "greet() (response: Text) { response = \"hello\" }", ), ]); let root = TemporaryDirectory::new(); fs::create_dir_all(root.path().join("user/service")).unwrap(); fs::write(root.path().join("main.dever"), "main() () {}\n").unwrap(); fs::write( root.path().join("user/service/greeting.dever"), "greet() (response: Text) { response = \"hello\" }\n", ) .unwrap(); let sources = SourceMap::load(root.path()).unwrap(); let errors = dever_core::check(&sources).unwrap_err(); assert!(errors.iter().any(|error| { error.code == "C003" && error.message.contains("application source must be") })); } #[test] fn only_app_crosses_domain_boundaries() { checked(&[ ( "user/account/domain.dever", "normalize(value: Text) (response: Text) { response = text.trim(value) }", ), ( "user/account/app.dever", "normalize(value: Text) (response: Text) { response = domain.normalize(value) }", ), ( "news/article/app.dever", "author(value: Text) (response: Text) { response = user.account.normalize(value) }", ), ( "user/profile/app.dever", "label(value: Text) (response: Text) { response = account.normalize(value) }", ), ( "main.dever", "main() (response: Text) { response = news.article.author(\" editor \" ) }", ), ]); rejected( &[ ( "user/account/domain.dever", "normalize(value: Text) (response: Text) { response = text.trim(value) }", ), ( "news/article/app.dever", "steal(value: Text) (response: Text) { response = user.account.domain.normalize(value) }", ), ], "C006", "not callable from this source role", ); } #[test] fn app_failures_must_use_app_owned_error_types() { rejected( &[ ( "user/account/domain.dever", "type RegistrationError { error InvalidName }\nreject() () { fail(RegistrationError.InvalidName) }", ), ( "user/account/app.dever", "register() () { domain.reject() }", ), ], "C006", "cannot expose private error type", ); checked(&[ ( "user/account/app.dever", "type RegistrationError { error InvalidName }\nregister() () { domain.reject() }", ), ( "user/account/domain.dever", "reject() () { fail(app.RegistrationError.InvalidName) }", ), ]); } #[test] fn model_operations_belong_to_the_owning_domain_app() { checked(&[ ("user/account/model.dever", "type Account { name: Text }"), ( "user/account/app.dever", "count() (total: Int) { total = model.count() }", ), ( "main.dever", "main() (total: Int) { total = user.account.count() }", ), ]); rejected( &[ ("user/account/model.dever", "type Account { name: Text }"), ( "main.dever", "main() (total: Int) { total = user.account.model.count() }", ), ], "C006", "Model operations are private", ); } #[test] fn api_is_an_http_entry_and_not_a_source_capability() { let sources = sources(&[ ( "user/account/app.dever", "greet(name: Text) (response: Text) { response = \"hello, \" + name }", ), ("user/account/api.dever", "get greeting = app.greet"), ]); let program = dever_core::check(&sources).unwrap(); let generated = dever_core::native::emit_project( &program, &sources, dever_runtime::config::RuntimeProfile::default(), ) .unwrap(); assert!(generated.contains("(\"GET\", \"/user/account/greeting\")")); rejected( &[ ("user/account/api.dever", "get greeting = app.greet"), ( "user/account/app.dever", "greet() (response: Text) { response = \"hello\" }", ), ( "news/article/app.dever", "steal() (response: Text) { response = user.account.api.greeting() }", ), ], "C004", "unknown", ); } #[test] fn main_contains_only_the_boot_function_and_port_rejects_business_bodies() { let root = TemporaryDirectory::new(); fs::write( root.path().join("main.dever"), "main() () {}\nhelper() () {}\n", ) .unwrap(); let sources = SourceMap::load(root.path()).unwrap(); let errors = dever_core::check(&sources).unwrap_err(); assert!( errors.iter().any(|error| { error.code == "C005" && error.message.contains("only the ordinary main") }) ); rejected( &[( "user/account/port.dever", "load() (response: Text) { response = \"value\" }", )], "C005", "bodyless", ); }