diff --git a/Cargo.lock b/Cargo.lock index 844f179c78b..266aef535f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,6 +160,7 @@ dependencies = [ name = "daglang-lower" version = "0.1.0" dependencies = [ + "daglang-resolve", "daglang-syntax", "daglang-typecheck", "gunbc-ir", diff --git a/core/daglang/daglang-cli/src/compile.rs b/core/daglang/daglang-cli/src/compile.rs new file mode 100644 index 00000000000..2b8f7c1dac8 --- /dev/null +++ b/core/daglang/daglang-cli/src/compile.rs @@ -0,0 +1,3862 @@ +use std::path::{Component, Path, PathBuf}; + +use crate::pipeline::PipelineContext; +use daglang_derive::{derive_artifacts, DerivedArtifacts}; +use daglang_emit::{emit_rust_bundle, EmissionBundle}; +use daglang_lower::{lower_typed_project, LoweredOp}; +use daglang_resolve::{ModuleGraph, ResolveError, ResolvedModule}; +use daglang_syntax::diagnostic::{Diagnostic, DiagnosticKind}; +use daglang_syntax::parser; +use daglang_typecheck::{typecheck_module_graph_with_options, TypecheckOptions}; +use gunbc_ir::Dag; + +#[derive(Debug)] +pub struct CompileOutput { + pub lowered_dag: Dag, + pub derived: DerivedArtifacts, + pub emitted: EmissionBundle, +} + +pub fn build_context(input: Option<&String>) -> PipelineContext { + let parsed = input.map(|value| normalize_cli_path(PathBuf::from(value))); + let (roots, target_file) = match parsed { + Some(path) if path.extension().and_then(|ext| ext.to_str()) == Some("dag") => { + let root = path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| ".".into())); + (vec![root], Some(path)) + } + Some(path) => (vec![path], None), + None => (vec![resolve_default_root()], None), + }; + + PipelineContext { roots, target_file } +} + +pub fn resolve_default_root() -> PathBuf { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + cwd.join("dsl") +} + +fn normalize_cli_path(path: PathBuf) -> PathBuf { + let absolute = if path.is_absolute() { + path + } else { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + cwd.join(path) + }; + normalize_path_components(&absolute) +} + +fn normalize_path_components(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(Path::new(std::path::MAIN_SEPARATOR_STR)), + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(segment) => normalized.push(segment), + } + } + normalized +} + +pub fn compile_from_context(context: &PipelineContext) -> Result { + let module_graph = discover_module_graph_for_context(context)?; + if context.target_file.is_none() { + validate_module_path_consistency(&module_graph, &context.roots)?; + } + let typed = typecheck_module_graph_with_options( + module_graph, + TypecheckOptions { + allow_unresolved_imports: context.target_file.is_some(), + }, + ) + .map_err(|errors| { + let mut message = String::from("typecheck errors:\n"); + for error in errors { + message.push_str(&format!(" {error}\n")); + } + message + })?; + let lowered = lower_typed_project(&typed).map_err(|error| format!("lower error: {error}"))?; + let derived = derive_artifacts(&lowered).map_err(|error| format!("derive error: {error}"))?; + let emitted = + emit_rust_bundle(&lowered, &derived).map_err(|error| format!("emit error: {error}"))?; + + Ok(CompileOutput { + lowered_dag: lowered, + derived, + emitted, + }) +} + +fn discover_module_graph_for_context(context: &PipelineContext) -> Result { + if let Some(target_file) = &context.target_file { + let source = std::fs::read_to_string(target_file) + .map_err(|error| format!("failed to read {}: {error}", target_file.display()))?; + let ast = parser::parse(&source).map_err(|errors| { + let mut message = String::from("compile diagnostics:\n"); + for error in &errors { + message.push_str(&format!( + " {}\n", + error.format_with_source(target_file, &source) + )); + } + message + })?; + let module_path = ast + .module_path + .as_ref() + .map(|module| module.node.segments.clone()) + .unwrap_or_else(|| { + target_file + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| vec![stem.to_string()]) + .unwrap_or_else(|| vec!["unknown".to_string()]) + }); + return Ok(ModuleGraph { + modules: vec![ResolvedModule { + path: target_file.clone(), + ast, + module_path, + dependencies: Vec::new(), + }], + }); + } + + ModuleGraph::discover(&context.roots).map_err(format_resolve_error) +} + +fn format_resolve_error(error: ResolveError) -> String { + match error { + ResolveError::ParseErrors(files) => { + let mut message = String::from("compile diagnostics:\n"); + let diagnostics = normalize_diagnostics( + files + .into_iter() + .flat_map(|(_path, diagnostics)| diagnostics) + .collect(), + ); + for diagnostic in diagnostics { + message.push_str(&format!(" {}\n", diagnostic.render())); + } + message + } + other => format!("resolve error: {other}"), + } +} + +fn normalize_diagnostics(mut diagnostics: Vec) -> Vec { + diagnostics.sort_by_key(|diag| { + ( + diagnostic_kind_rank(&diag.kind), + diag.file + .as_ref() + .map(|file| file.display().to_string()) + .unwrap_or_default(), + diag.line.unwrap_or_default(), + diag.column.unwrap_or_default(), + diag.message.clone(), + ) + }); + diagnostics.dedup_by(|a, b| { + a.kind == b.kind + && a.file == b.file + && a.line == b.line + && a.column == b.column + && a.message == b.message + }); + diagnostics +} + +fn diagnostic_kind_rank(kind: &DiagnosticKind) -> u8 { + match kind { + DiagnosticKind::Lex => 0, + DiagnosticKind::Parse => 1, + DiagnosticKind::Resolve => 2, + DiagnosticKind::Pipeline => 3, + } +} + +fn validate_module_path_consistency( + graph: &ModuleGraph, + roots: &[PathBuf], +) -> Result<(), String> { + let mismatches = graph + .modules + .iter() + .filter_map(|module| { + let derived = derive_module_path(&module.path, roots)?; + (derived != module.module_path).then_some(( + module.path.clone(), + module.module_path.join("."), + derived.join("."), + )) + }) + .collect::>(); + + if mismatches.is_empty() { + return Ok(()); + } + + let mut message = String::from("module path mismatches:\n"); + for (path, declared, derived) in mismatches { + message.push_str(&format!( + " {}: declared `{declared}` but filesystem implies `{derived}`\n", + path.display() + )); + } + Err(message) +} + +fn derive_module_path(path: &std::path::Path, roots: &[PathBuf]) -> Option> { + for root in roots { + if let Ok(relative) = path.strip_prefix(root) { + return Some( + relative + .with_extension("") + .components() + .filter_map(|segment| segment.as_os_str().to_str().map(String::from)) + .collect(), + ); + } + } + None +} + +pub fn render_expand(dag: &Dag) -> String { + let mut out = String::new(); + out.push_str("Nodes:\n"); + for node in &dag.nodes { + let kind = match &node.body { + gunbc_ir::node::NodeBody::Opaque(LoweredOp::Callable { kind, module, name }) => { + format!("callable::{kind:?} {module}.{name}") + } + gunbc_ir::node::NodeBody::Opaque(LoweredOp::Pipeline { + module, + name, + stages, + }) => format!("pipeline {module}.{name} ({stages} stages)"), + gunbc_ir::node::NodeBody::SubDag(_) => "subdag".to_string(), + }; + + out.push_str(&format!(" - {} [{kind}]\n", node.id.0)); + if !node.inputs.is_empty() { + out.push_str(" inputs:\n"); + for input in &node.inputs { + out.push_str(&format!( + " * {}: {} ({})\n", + input.name.0, input.type_id.0, input.cardinality + )); + } + } + if !node.outputs.is_empty() { + out.push_str(" outputs:\n"); + for output in &node.outputs { + out.push_str(&format!( + " * {}: {} ({})\n", + output.name.0, output.type_id.0, output.cardinality + )); + } + } + } + + out.push_str("Edges:\n"); + if dag.edges.is_empty() { + out.push_str(" (none)\n"); + } else { + for edge in &dag.edges { + out.push_str(&format!( + " - {}.{} -> {}.{}\n", + edge.from_node.0, edge.from_port.0, edge.to_node.0, edge.to_port.0 + )); + } + } + out +} + +pub fn render_manifest(derived: &DerivedArtifacts) -> String { + let manifest = &derived.manifest; + let obligations = &derived.obligations; + let mut out = String::new(); + out.push_str("ProgressManifest:\n"); + out.push_str(&format!(" total_nodes: {}\n", manifest.total_nodes)); + out.push_str(&format!(" total_edges: {}\n", manifest.total_edges)); + out.push_str(" waves:\n"); + for (index, wave) in manifest.waves.iter().enumerate() { + out.push_str(&format!(" [{index}] {}\n", wave.join(", "))); + } + out.push_str(&format!( + " entrypoint_nodes: {}\n", + if manifest.entrypoint_nodes.is_empty() { + "(none)".to_string() + } else { + manifest.entrypoint_nodes.join(", ") + } + )); + out.push_str(&format!( + " boundary_nodes: {}\n", + if manifest.boundary_nodes.is_empty() { + "(none)".to_string() + } else { + manifest.boundary_nodes.join(", ") + } + )); + out.push_str("TestObligations:\n"); + out.push_str(&format!( + " dry_run_completion_required: {}\n", + obligations.dry_run_completion_required + )); + out.push_str(&format!( + " transport_execution_targets: {}\n", + obligations.transport_execution_targets + )); + out.push_str(&format!( + " pure_node_determinism_targets: {}\n", + obligations.pure_node_determinism_targets + )); + out.push_str(&format!( + " service_transport_prepare_targets: {}\n", + obligations.service_transport_prepare_targets + )); + out.push_str(&format!( + " service_transport_execute_targets: {}\n", + obligations.service_transport_execute_targets + )); + out.push_str(&format!( + " service_transport_parse_targets: {}\n", + obligations.service_transport_parse_targets + )); + out.push_str(&format!( + " service_param_source_targets: {}\n", + obligations.service_param_source_targets + )); + out.push_str(&format!( + " resource_provide_targets: {}\n", + obligations.resource_provide_targets + )); + out.push_str(&format!( + " resource_acquire_targets: {}\n", + obligations.resource_acquire_targets + )); + out.push_str(&format!( + " resource_release_targets: {}\n", + obligations.resource_release_targets + )); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_file(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "daglang_cli_compile_{name}_{}_{}.dag", + std::process::id(), + nanos + )) + } + + fn assert_typecheck_stage_error(error: &str) { + assert!(error.contains("typecheck errors")); + assert!(!error.contains("lower error")); + } + + fn assert_lower_stage_error(error: &str) { + assert!(error.contains("lower error")); + assert!(!error.contains("typecheck errors")); + } + + #[test] + fn build_context_normalizes_absolute_directory_input_components() { + let root = std::env::temp_dir().join(format!( + "daglang_build_context_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + let normalized_root = root.join("sample"); + std::fs::create_dir_all(&normalized_root).expect("failed to create temp directory root"); + let input = root.join("sample").join(".").join("nested").join(".."); + let input_str = input.to_string_lossy().to_string(); + + let context = build_context(Some(&input_str)); + assert_eq!(context.roots, vec![normalized_root.clone()]); + assert!(context.target_file.is_none()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn build_context_normalizes_absolute_single_file_input_components() { + let root = std::env::temp_dir().join(format!( + "daglang_build_context_file_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + let normalized_file = root.join("sample/main.dag"); + std::fs::create_dir_all(normalized_file.parent().expect("file should have parent")) + .expect("failed to create temp file parent"); + std::fs::write(&normalized_file, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write temp dag file"); + let input = root.join("sample").join("nested").join("..").join("main.dag"); + let input_str = input.to_string_lossy().to_string(); + + let context = build_context(Some(&input_str)); + assert_eq!( + context.roots, + vec![normalized_file + .parent() + .expect("file should have parent") + .to_path_buf()] + ); + assert_eq!(context.target_file, Some(normalized_file.clone())); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn build_context_default_root_is_cwd_dsl() { + let context = build_context(None); + let cwd = std::env::current_dir().expect("cwd should resolve"); + assert_eq!(context.roots, vec![cwd.join("dsl")]); + assert!(context.target_file.is_none()); + } + + #[test] + fn compile_single_file_makegen_produces_non_empty_outputs() { + let file = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../dsl/tools/makegen.dag"); + let context = PipelineContext { + roots: vec![file.parent().expect("file should have parent").to_path_buf()], + target_file: Some(file), + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + let rendered = render_manifest(&output.derived); + assert!(rendered.contains("TestObligations:")); + assert!(rendered.contains("service_transport_prepare_targets:")); + assert!(rendered.contains("service_param_source_targets:")); + assert!(rendered.contains("resource_provide_targets:")); + } + + #[test] + fn compile_reports_pipeline_diagnostics_for_invalid_source() { + let broken_file = unique_temp_file("broken"); + std::fs::write(&broken_file, "module broken\nfn bad( -> Unit {}") + .expect("failed to write broken source"); + + let context = PipelineContext { + roots: vec![ + broken_file + .parent() + .expect("temp file should have parent") + .to_path_buf(), + ], + target_file: Some(broken_file.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert!(error.contains("compile diagnostics")); + assert!(error.contains(":2:")); + assert!(!error.contains("typecheck errors")); + assert!(!error.contains("lower error")); + + std::fs::remove_file(broken_file).expect("failed to cleanup broken source"); + } + + #[test] + fn compile_directory_reports_module_path_mismatch() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_mismatch_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + std::fs::write( + root.join("main.dag"), + "module mismatch.main\nfn run() -> Unit {}", + ) + .expect("failed to write source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + let error = compile_from_context(&context).expect_err("compile should fail"); + assert!(error.contains("module path mismatches")); + assert!(error.contains("main")); + assert!(!error.contains("typecheck errors")); + assert!(!error.contains("lower error")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_sorts_lex_before_parse_diagnostics() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_lex_before_parse_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + std::fs::write(root.join("a_parse.dag"), "module sample.parse\nfn") + .expect("failed to write parse-error file"); + std::fs::write(root.join("z_lex.dag"), "module sample.lex\n$\n") + .expect("failed to write lex-error file"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + let first_diagnostic_line = error + .lines() + .find(|line| line.contains(".dag:")) + .expect("expected at least one file diagnostic line"); + assert!( + first_diagnostic_line.contains("z_lex.dag"), + "lex diagnostics should sort before parse diagnostics: {error}" + ); + assert!(error.contains("a_parse.dag")); + assert!(error.contains("unexpected character '$'")); + assert!(!error.contains("typecheck errors")); + assert!(!error.contains("lower error")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_unresolved_service_call_fails_in_lower_stage() { + let fixture = unique_temp_file("single_file_unresolved_service_call"); + std::fs::write( + &fixture, + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = MissingStorage.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write unresolved service-call fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_lower_stage_error(&error); + assert!(error.contains("unresolved service call")); + assert!(error.contains("MissingStorage.read")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_unresolved_service_call_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_unresolved_service_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +func run(path: String) -> { body: String } { + let response = MissingStorage.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write unresolved service-call source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("unresolved service call")); + assert!(error.contains("MissingStorage.read")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_uses_bound_resource_capability_call_succeeds() { + let fixture = unique_temp_file("single_file_uses_bound_resource_capability_call"); + std::fs::write( + &fixture, + r#"module sample.resources +resource Filesystem { + capability read { + input { path: String } + output { body: String } + } +} +func run(path: String) -> { body: String } uses fs: Filesystem { + let response = fs.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write resource-bound capability fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_uses_bound_resource_capability_call_succeeds() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_resource_bound_service_call_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource Filesystem { + capability read { + input { path: String } + output { body: String } + } +} +func run(path: String) -> { body: String } uses fs: Filesystem { + let response = fs.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write resource-bound capability source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_unresolved_uses_fails_in_lower_stage() { + let fixture = unique_temp_file("single_file_unresolved_uses"); + std::fs::write( + &fixture, + r#"module sample.uses +func run() -> { ok: Bool } uses fs: MissingResource { + return { ok: true } +} +"#, + ) + .expect("failed to write unresolved uses fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_lower_stage_error(&error); + assert!(error.contains("unresolved used resource")); + assert!(error.contains("MissingResource")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_unresolved_uses_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_unresolved_uses_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +func run() -> { ok: Bool } uses fs: MissingResource { + return { ok: true } +} +"#, + ) + .expect("failed to write unresolved uses source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("unknown used resource type")); + assert!(error.contains("MissingResource")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_uses_resource_with_runtime_config_suffix_succeeds() { + let fixture = unique_temp_file("single_file_uses_resource_with_config_suffix"); + std::fs::write( + &fixture, + r#"module sample.uses +resource Filesystem {} +func run() -> { ok: Bool } uses fs: Filesystem(mode: ReadWrite) { + return { ok: true } +} +"#, + ) + .expect("failed to write configured uses fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_uses_resource_with_runtime_config_suffix_succeeds() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_uses_config_suffix_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource Filesystem {} +func run() -> { ok: Bool } uses fs: Filesystem(mode: ReadWrite) { + return { ok: true } +} +"#, + ) + .expect("failed to write configured uses source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_unresolved_provides_fails_in_lower_stage() { + let fixture = unique_temp_file("single_file_unresolved_provides"); + std::fs::write( + &fixture, + r#"module sample.provides +func run() -> { ok: Bool } provides out: MissingResource { + return { ok: true } +} +"#, + ) + .expect("failed to write unresolved provides fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_lower_stage_error(&error); + assert!(error.contains("unresolved provided resource")); + assert!(error.contains("MissingResource")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_unresolved_provides_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_unresolved_provides_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +func run() -> { ok: Bool } provides out: MissingResource { + return { ok: true } +} +"#, + ) + .expect("failed to write unresolved provides source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("unknown provided resource type")); + assert!(error.contains("MissingResource")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_provides_resource_with_runtime_config_suffix_succeeds() { + let fixture = unique_temp_file("single_file_provides_resource_with_config_suffix"); + std::fs::write( + &fixture, + r#"module sample.provides +resource ArtifactStore { + release { + let done = true + } +} +func run() -> { ok: Bool } provides out: ArtifactStore(kind: temporary) { + return { ok: true } +} +"#, + ) + .expect("failed to write configured provides fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_provides_resource_with_runtime_config_suffix_succeeds() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_provides_config_suffix_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource ArtifactStore { + release { + let done = true + } +} +func run() -> { ok: Bool } provides out: ArtifactStore(kind: temporary) { + return { ok: true } +} +"#, + ) + .expect("failed to write configured provides source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_allows_unresolved_imports_in_relaxed_mode() { + let fixture = unique_temp_file("single_file_unresolved_import"); + std::fs::write( + &fixture, + r#"module sample.single +import missing.dep +fn run() -> Unit {} +"#, + ) + .expect("failed to write unresolved import fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_unresolved_import_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_unresolved_import_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +import missing.dep +fn run() -> Unit {} +"#, + ) + .expect("failed to write unresolved import source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("unresolved import")); + assert!(error.contains("missing.dep")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_allows_unresolved_call_targets_in_relaxed_mode() { + let fixture = unique_temp_file("single_file_unresolved_call_target"); + std::fs::write( + &fixture, + r#"module sample.single +fn run() -> Unit { + missing() +} +"#, + ) + .expect("failed to write unresolved callable fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_unresolved_call_target_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_unresolved_call_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn run() -> Unit { + missing() +} +"#, + ) + .expect("failed to write unresolved callable source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("unresolved call target")); + assert!(error.contains("missing")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_collection_intrinsics_typecheck_in_strict_mode() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_collection_intrinsics_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type Stage { + success: Bool, + skipped: Bool, + name: String +} +fn summarize(stages: List) -> Int { + let passed = stages |> filter(s => s.success) |> count() + let labels = stages |> map(s => s.name) |> join(",") + let done = labels |> ends_with("ok") + passed +} +"#, + ) + .expect("failed to write collection intrinsic source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_function_typed_parameter_calls_typecheck_in_strict_mode() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_fn_typed_param_calls_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn apply(value: Int, callback: fn(Int) -> Int) -> Int { + callback(value) +} +"#, + ) + .expect("failed to write callback source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_sum_variant_constructor_calls_typecheck_in_strict_mode() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_sum_variant_constructor_calls_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type CloudConfig + = GcpConfig { project: String, region: String } + | AwsConfig { region: String } + +fn make_gcp() -> CloudConfig { + GcpConfig(project: "gunbc", region: "us-central1") +} +"#, + ) + .expect("failed to write constructor source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_zero_arity_variant_identifier_returns_typecheck_in_strict_mode() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_zero_arity_variant_identifier_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type Environment = Dev | Ci +fn env() -> Environment { + Dev +} +"#, + ) + .expect("failed to write zero-arity variant source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_lossy_match_fn_body_does_not_fail_missing_tail_mismatch() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_lossy_match_body_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type CloudConfig + = GcpConfig { project: String } + | AwsConfig { account: String } +type CloudProvider = Gcp | Aws + +fn provider_of(config: CloudConfig) -> CloudProvider { + match config { + GcpConfig { ... } => Gcp + AwsConfig { ... } => Aws + } +} +"#, + ) + .expect("failed to write lossy match source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_std_helper_intrinsics_typecheck_in_strict_mode() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_std_helper_intrinsics_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type DocgenSources {} + +fn run(sources: DocgenSources, payload: String) -> String { + let a = "template" |> replace_section("section", "value") + let b = render_test_listings(sources: sources) + let c = render_graph_structure(sources: sources) + let d = render_source_artifacts(sources: sources) + let e = compute_topology_diff(current: "{}", base: "{}") + let f = render_annotated_mermaid(diff: e, topology: "{}", title: "title") + let g = detect_runtime() + let h = generate() + let i = now() + let j = build_token( + payload: payload, + scheme: "Bearer", + header_name: "Authorization", + source_id: "source", + required_scopes: ["gist"] + ) + a +} +"#, + ) + .expect("failed to write helper intrinsic source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_duplicate_service_suppresses_ambiguous_service_call() { + let fixture = unique_temp_file("single_file_duplicate_service"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write duplicate service fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate definition `FsStorage`")); + assert!(!error.contains("ambiguous service call")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_duplicate_service_reports_ambiguous_service_call() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_duplicate_service_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write duplicate service source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate definition `FsStorage`")); + assert!(error.contains("ambiguous service call")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_duplicate_callable_suppresses_ambiguous_call_target() { + let fixture = unique_temp_file("single_file_duplicate_callable"); + std::fs::write( + &fixture, + r#"module sample.single +fn helper() -> String { "a" } +fn helper() -> String { "b" } +fn run() -> String { helper() } +"#, + ) + .expect("failed to write duplicate callable fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate definition `helper`")); + assert!(!error.contains("ambiguous call target")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_duplicate_callable_reports_ambiguous_call_target() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_duplicate_callable_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn helper() -> String { "a" } +fn helper() -> String { "b" } +fn run() -> String { helper() } +"#, + ) + .expect("failed to write duplicate callable source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate definition `helper`")); + assert!(error.contains("ambiguous call target")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_duplicate_resource_uses_suppresses_ambiguous_used_type() { + let fixture = unique_temp_file("single_file_duplicate_resource_uses"); + std::fs::write( + &fixture, + r#"module sample.single +resource SharedResource {} +resource SharedResource {} +func run() -> { ok: Bool } uses fs: SharedResource { return { ok: true } } +"#, + ) + .expect("failed to write duplicate resource-uses fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate definition `SharedResource`")); + assert!(!error.contains("ambiguous used resource type")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_duplicate_resource_uses_reports_ambiguous_used_type() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_duplicate_resource_uses_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource SharedResource {} +resource SharedResource {} +func run() -> { ok: Bool } uses fs: SharedResource { return { ok: true } } +"#, + ) + .expect("failed to write duplicate resource-uses source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate definition `SharedResource`")); + assert!(error.contains("ambiguous used resource type")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_duplicate_resource_provides_suppresses_ambiguous_provided_type() { + let fixture = unique_temp_file("single_file_duplicate_resource_provides"); + std::fs::write( + &fixture, + r#"module sample.single +resource SharedResource {} +resource SharedResource {} +func run() -> { ok: Bool } provides out: SharedResource { return { ok: true } } +"#, + ) + .expect("failed to write duplicate resource-provides fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate definition `SharedResource`")); + assert!(!error.contains("ambiguous provided resource type")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_duplicate_resource_provides_reports_ambiguous_provided_type() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_duplicate_resource_provides_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource SharedResource {} +resource SharedResource {} +func run() -> { ok: Bool } provides out: SharedResource { return { ok: true } } +"#, + ) + .expect("failed to write duplicate resource-provides source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate definition `SharedResource`")); + assert!(error.contains("ambiguous provided resource type")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_unresolved_service_interface_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_unresolved_service_interface"); + std::fs::write( + &fixture, + r#"module sample.services +service FsStorage implements MissingStorage { + operation read(path: String) -> { body: String } +} +"#, + ) + .expect("failed to write unresolved service-interface fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("`FsStorage` references unresolved interface `MissingStorage`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_unresolved_resource_interface_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_unresolved_resource_interface"); + std::fs::write( + &fixture, + r#"module sample.resources +resource Disk implements MissingStorage { + capability read { + input { path: String } + output { body: String } + } +} +"#, + ) + .expect("failed to write unresolved resource-interface fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("`Disk` references unresolved interface `MissingStorage`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_unresolved_service_interface_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_unresolved_service_interface_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +service FsStorage implements MissingStorage { + operation read(path: String) -> { body: String } +} +"#, + ) + .expect("failed to write unresolved service-interface source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("`FsStorage` references unresolved interface `MissingStorage`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_unresolved_resource_interface_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_unresolved_resource_interface_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource Disk implements MissingStorage { + capability read { + input { path: String } + output { body: String } + } +} +"#, + ) + .expect("failed to write unresolved resource-interface source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("`Disk` references unresolved interface `MissingStorage`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_duplicate_interface_reports_ambiguous_implements() { + let fixture = unique_temp_file("single_file_duplicate_interface"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +"#, + ) + .expect("failed to write duplicate-interface fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate definition `Storage` in module `sample.single`")); + assert!(error.contains("`FsStorage` references ambiguous interface `Storage`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_duplicate_interface_reports_ambiguous_implements() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_duplicate_interface_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +"#, + ) + .expect("failed to write duplicate-interface source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate definition `Storage` in module `sample.main`")); + assert!(error.contains("`FsStorage` references ambiguous interface `Storage`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_unit_return_without_tail_expression_succeeds() { + let fixture = unique_temp_file("single_file_unit_without_tail"); + std::fs::write( + &fixture, + r#"module sample.single +fn run() -> Unit { + let x = 42 +} +"#, + ) + .expect("failed to write Unit-return fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_unit_return_without_tail_expression_succeeds() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_unit_without_tail_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn run() -> Unit { + let x = 42 +} +"#, + ) + .expect("failed to write Unit-return source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_missing_tail_non_unit_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_non_unit_without_tail"); + std::fs::write( + &fixture, + r#"module sample.single +fn run() -> String { + let x = 42 +} +"#, + ) + .expect("failed to write non-Unit return fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("type mismatch: expected `String`, got `Unit`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_missing_tail_non_unit_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_non_unit_without_tail_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn run() -> String { + let x = 42 +} +"#, + ) + .expect("failed to write non-Unit return source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("type mismatch: expected `String`, got `Unit`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_call_arity_mismatch_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_call_arity_mismatch"); + std::fs::write( + &fixture, + r#"module sample.calls +fn fmt(value: String) -> String { value } +fn run() -> String { fmt() } +"#, + ) + .expect("failed to write call-arity fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("call arity mismatch")); + assert!(error.contains("fmt")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_unknown_named_call_argument_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_unknown_named_call_argument"); + std::fs::write( + &fixture, + r#"module sample.calls +fn fmt(value: String) -> String { value } +fn run() -> String { fmt(text: "ok") } +"#, + ) + .expect("failed to write unknown-arg fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("unknown named argument")); + assert!(error.contains("text")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_duplicate_named_call_argument_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_duplicate_named_call_argument"); + std::fs::write( + &fixture, + r#"module sample.calls +fn fmt(value: String) -> String { value } +fn run() -> String { fmt(value: "a", value: "b") } +"#, + ) + .expect("failed to write duplicate-arg fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate named argument")); + assert!(error.contains("value")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_service_call_arity_mismatch_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_service_call_arity_mismatch"); + std::fs::write( + &fixture, + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run() -> { body: String } { + let response = FsStorage.read() + return { body: response.body } +} +"#, + ) + .expect("failed to write service call-arity fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("service call arity mismatch")); + assert!(error.contains("FsStorage.read")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_unknown_named_service_argument_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_unknown_named_service_argument"); + std::fs::write( + &fixture, + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run() -> { body: String } { + let response = FsStorage.read(name: "README.md") + return { body: response.body } +} +"#, + ) + .expect("failed to write unknown service-arg fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("unknown named argument")); + assert!(error.contains("name")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_duplicate_named_service_argument_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_duplicate_named_service_argument"); + std::fs::write( + &fixture, + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run() -> { body: String } { + let response = FsStorage.read(path: "a", path: "b") + return { body: response.body } +} +"#, + ) + .expect("failed to write duplicate service-arg fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate named argument")); + assert!(error.contains("path")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_undefined_type_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_undefined_type"); + std::fs::write( + &fixture, + r#"module sample.types +fn run(input: MissingType) -> String { "ok" } +"#, + ) + .expect("failed to write undefined-type fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("undefined type `MissingType")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_type_mismatch_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_type_mismatch"); + std::fs::write( + &fixture, + r#"module sample.types +fn run() -> String { return 42 } +"#, + ) + .expect("failed to write type-mismatch fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("type mismatch: expected `String`, got `Int`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_implicit_return_type_mismatch_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_implicit_return_type_mismatch"); + std::fs::write( + &fixture, + r#"module sample.types +fn run() -> String { 42 } +"#, + ) + .expect("failed to write implicit-return mismatch fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("type mismatch: expected `String`, got `Int`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_no_such_field_record_literal_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_no_such_field_record_literal"); + std::fs::write( + &fixture, + r#"module sample.types +func run() -> { body: String } { + let payload = { body: "ok" } + return { body: payload.missing } +} +"#, + ) + .expect("failed to write no-such-field record-literal fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("type `Record` has no field `missing`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_no_such_field_named_record_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_no_such_field_named_record"); + std::fs::write( + &fixture, + r#"module sample.types +type Payload { body: String } +fn run(input: Payload) -> String { input.missing } +"#, + ) + .expect("failed to write no-such-field named-record fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("type `Payload` has no field `missing`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_unsatisfiable_refinement_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_unsatisfiable_refinement"); + std::fs::write( + &fixture, + r#"module sample.types +fn run(value: Int @range(min: 5, max: 1)) -> Int { value } +"#, + ) + .expect("failed to write unsatisfiable-refinement fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("unsatisfiable refinement on `Int`: range min 5 exceeds max 1")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_generic_arity_mismatch_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_generic_arity_mismatch"); + std::fs::write( + &fixture, + r#"module sample.types +fn run(values: Map) -> Int { 1 } +"#, + ) + .expect("failed to write generic-arity mismatch fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("generic arity mismatch for `Map`: expected 2, got 1")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_user_defined_generic_arity_mismatch_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_user_defined_generic_arity_mismatch"); + std::fs::write( + &fixture, + r#"module sample.types +type Box = T +fn run(values: Box) -> String { values } +"#, + ) + .expect("failed to write user-defined generic-arity mismatch fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("generic arity mismatch for `Box`: expected 1, got 2")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_undefined_type_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_undefined_type_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn run(input: MissingType) -> String { "ok" } +"#, + ) + .expect("failed to write undefined-type source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("undefined type `MissingType")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_type_mismatch_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_type_mismatch_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn run() -> String { return 42 } +"#, + ) + .expect("failed to write type-mismatch source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("type mismatch: expected `String`, got `Int`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_implicit_return_type_mismatch_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_implicit_return_mismatch_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn run() -> String { 42 } +"#, + ) + .expect("failed to write implicit-return mismatch source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("type mismatch: expected `String`, got `Int`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_no_such_field_record_literal_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_no_such_field_record_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +func run() -> { body: String } { + let payload = { body: "ok" } + return { body: payload.missing } +} +"#, + ) + .expect("failed to write no-such-field record-literal source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("type `Record` has no field `missing`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_no_such_field_named_record_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_no_such_field_named_record_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type Payload { body: String } +fn run(input: Payload) -> String { input.missing } +"#, + ) + .expect("failed to write no-such-field named-record source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("type `Payload` has no field `missing`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_unsatisfiable_refinement_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_unsatisfiable_refinement_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn run(value: Int @range(min: 5, max: 1)) -> Int { value } +"#, + ) + .expect("failed to write unsatisfiable-refinement source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("unsatisfiable refinement on `Int`: range min 5 exceeds max 1")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_generic_arity_mismatch_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_generic_arity_mismatch_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn run(values: Map) -> Int { 1 } +"#, + ) + .expect("failed to write generic-arity mismatch source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("generic arity mismatch for `Map`: expected 2, got 1")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_user_defined_generic_arity_mismatch_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_user_defined_generic_arity_mismatch_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type Box = T +fn run(values: Box) -> String { values } +"#, + ) + .expect("failed to write user-defined generic-arity mismatch source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("generic arity mismatch for `Box`: expected 1, got 2")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_call_arity_mismatch_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_call_arity_mismatch_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn fmt(value: String) -> String { value } +fn run() -> String { fmt() } +"#, + ) + .expect("failed to write call-arity mismatch source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("call arity mismatch")); + assert!(error.contains("fmt")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_call_with_defaulted_params_succeeds() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_call_defaults_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn greet(name: String, punctuation: String = "!") -> String { name } +fn run() -> String { greet(name: "hi") } +"#, + ) + .expect("failed to write defaulted callable source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_pattern_call_with_extra_named_wiring_args_succeeds() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_pattern_wiring_args_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +pattern ensure(should_act: Bool = true) -> { acted: Bool } { + return { acted: should_act } +} +fn run() -> Bool { + let result = ensure(check: true, action: false) + result.acted +} +"#, + ) + .expect("failed to write pattern wiring source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_generic_fn_type_params_typecheck_in_strict_mode() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_generic_fn_type_params_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn identity(value: T) -> T { + value +} +fn relay(value: T) -> T { + identity(value: value) +} +"#, + ) + .expect("failed to write generic fn source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_generic_pattern_type_params_typecheck_in_strict_mode() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_generic_pattern_type_params_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +pattern passthrough(value: T) -> { value: T } { + return { value: value } +} +fn relay(value: T) -> T { + let result = passthrough(value: value) + result.value +} +"#, + ) + .expect("failed to write generic pattern source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_named_record_literal_return_succeeds_in_strict_mode() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_named_record_literal_return_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type StageResult { + success: Bool, + skipped: Bool +} +fn result() -> StageResult { + { success: true, skipped: false } +} +"#, + ) + .expect("failed to write named record literal source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_resource_config_named_return_succeeds_in_strict_mode() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_resource_config_named_return_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource GcsBucket { + config { + name: String, + project: String + } +} +fn gcp_dev_storage() -> GcsBucket.Config { + { name: "gunbc-dev-artifacts", project: "gunbai-auto" } +} +"#, + ) + .expect("failed to write resource config source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_unknown_named_call_argument_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_unknown_named_call_arg_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn fmt(value: String) -> String { value } +fn run() -> String { fmt(text: "ok") } +"#, + ) + .expect("failed to write unknown named-call argument source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("unknown named argument")); + assert!(error.contains("text")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_duplicate_named_call_argument_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_duplicate_named_call_arg_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn fmt(value: String) -> String { value } +fn run() -> String { fmt(value: "a", value: "b") } +"#, + ) + .expect("failed to write duplicate named-call argument source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate named argument")); + assert!(error.contains("value")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_service_call_arity_mismatch_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_service_call_arity_mismatch_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run() -> { body: String } { + let response = FsStorage.read() + return { body: response.body } +} +"#, + ) + .expect("failed to write service call-arity mismatch source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("service call arity mismatch")); + assert!(error.contains("FsStorage.read")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_service_call_with_defaulted_inputs_succeeds() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_service_call_defaults_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { + path: String, + recursive: Bool = false + } + output { ok: Bool } + } +} +service FsStorage implements Storage { + operation read(path: String, recursive: Bool = false) -> { ok: Bool } +} +func run() -> { ok: Bool } { + let response = FsStorage.read(path: "/tmp") + return { ok: response.ok } +} +"#, + ) + .expect("failed to write defaulted service-call source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let output = compile_from_context(&context).expect("compile should succeed"); + assert!(!output.lowered_dag.nodes.is_empty()); + assert!(output.derived.manifest.total_nodes > 0); + assert!(!output.emitted.files.is_empty()); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_unknown_named_service_argument_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_unknown_named_service_arg_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run() -> { body: String } { + let response = FsStorage.read(name: "README.md") + return { body: response.body } +} +"#, + ) + .expect("failed to write unknown named service-argument source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("unknown named argument")); + assert!(error.contains("name")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_duplicate_named_service_argument_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_duplicate_named_service_arg_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run() -> { body: String } { + let response = FsStorage.read(path: "a", path: "b") + return { body: response.body } +} +"#, + ) + .expect("failed to write duplicate named service-argument source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate named argument")); + assert!(error.contains("path")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_single_file_duplicate_parameter_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_duplicate_parameter"); + std::fs::write( + &fixture, + r#"module sample.single +fn run(a: String, a: Int) -> String { a } +"#, + ) + .expect("failed to write duplicate-parameter fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate parameter `a` in `run`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_duplicate_output_field_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_duplicate_output_field"); + std::fs::write( + &fixture, + r#"module sample.single +func run() -> { ok: Bool, ok: String } { return { ok: true } } +"#, + ) + .expect("failed to write duplicate-output fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate output field `ok` in `run`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_duplicate_uses_binding_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_duplicate_uses_binding"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { capability read { input { path: String } output { body: String } } } +func run() -> { ok: Bool } uses fs: Storage uses fs: Storage { return { ok: true } } +"#, + ) + .expect("failed to write duplicate-uses fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate uses binding `fs` in `run`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_duplicate_provides_binding_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_duplicate_provides_binding"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { capability read { input { path: String } output { body: String } } } +func run() -> { ok: Bool } provides out: Storage provides out: Storage { return { ok: true } } +"#, + ) + .expect("failed to write duplicate-provides fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate provides binding `out` in `run`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_use_provide_binding_conflict_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_use_provide_binding_conflict"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { capability read { input { path: String } output { body: String } } } +func run() -> { ok: Bool } uses io: Storage provides io: Storage { return { ok: true } } +"#, + ) + .expect("failed to write use/provide conflict fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("binding `io` is declared in both uses/provides in `run`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_missing_resource_capability_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_missing_resource_capability"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } + capability write { + input { path: String, body: String } + output { ok: Bool } + } +} +resource Disk implements Storage { + capability read { + input { path: String } + output { body: String } + } +} +"#, + ) + .expect("failed to write missing-resource-capability fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("resource `Disk` is missing capability `write` for interface `Storage`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_missing_service_operation_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_missing_service_operation"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } + capability write { + input { path: String, body: String } + output { ok: Bool } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +"#, + ) + .expect("failed to write missing-service-operation fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("service `FsStorage` is missing operation `write` for interface `Storage`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_service_interface_signature_mismatch_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_interface_signature_mismatch"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: Int) -> { body: String } +} +"#, + ) + .expect("failed to write service-signature-mismatch fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("`FsStorage` does not match `Storage.read` contract")); + assert!(error.contains("expected `String` but found `Int`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_single_file_resource_interface_signature_mismatch_fails_in_typecheck_stage() { + let fixture = unique_temp_file("single_file_resource_signature_mismatch"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +resource Disk implements Storage { + capability read { + input { path: Int } + output { body: String } + } +} +"#, + ) + .expect("failed to write resource-signature-mismatch fixture"); + + let context = PipelineContext { + roots: vec![fixture.parent().expect("fixture should have parent").to_path_buf()], + target_file: Some(fixture.clone()), + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("`Disk` does not match `Storage.read` contract")); + assert!(error.contains("expected `String` but found `Int`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); + } + + #[test] + fn compile_directory_duplicate_parameter_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_duplicate_parameter_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn run(a: String, a: Int) -> String { a } +"#, + ) + .expect("failed to write duplicate-parameter source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate parameter `a` in `run`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_duplicate_output_field_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_duplicate_output_field_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +func run() -> { ok: Bool, ok: String } { return { ok: true } } +"#, + ) + .expect("failed to write duplicate-output source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate output field `ok` in `run`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_duplicate_uses_binding_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_duplicate_uses_binding_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { capability read { input { path: String } output { body: String } } } +func run() -> { ok: Bool } uses fs: Storage uses fs: Storage { return { ok: true } } +"#, + ) + .expect("failed to write duplicate-uses source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate uses binding `fs` in `run`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_duplicate_provides_binding_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_duplicate_provides_binding_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { capability read { input { path: String } output { body: String } } } +func run() -> { ok: Bool } provides out: Storage provides out: Storage { return { ok: true } } +"#, + ) + .expect("failed to write duplicate-provides source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("duplicate provides binding `out` in `run`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_use_provide_binding_conflict_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_use_provide_binding_conflict_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { capability read { input { path: String } output { body: String } } } +func run() -> { ok: Bool } uses io: Storage provides io: Storage { return { ok: true } } +"#, + ) + .expect("failed to write use/provide conflict source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("binding `io` is declared in both uses/provides in `run`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_missing_resource_capability_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_missing_resource_capability_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } + capability write { + input { path: String, body: String } + output { ok: Bool } + } +} +resource Disk implements Storage { + capability read { + input { path: String } + output { body: String } + } +} +"#, + ) + .expect("failed to write missing-resource-capability source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("resource `Disk` is missing capability `write` for interface `Storage`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_missing_service_operation_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_missing_service_operation_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } + capability write { + input { path: String, body: String } + output { ok: Bool } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +"#, + ) + .expect("failed to write missing-service-operation source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("service `FsStorage` is missing operation `write` for interface `Storage`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_service_interface_signature_mismatch_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_service_signature_mismatch_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: Int) -> { body: String } +} +"#, + ) + .expect("failed to write service-signature-mismatch source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("`FsStorage` does not match `Storage.read` contract")); + assert!(error.contains("expected `String` but found `Int`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_resource_interface_signature_mismatch_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_resource_signature_mismatch_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +resource Disk implements Storage { + capability read { + input { path: Int } + output { body: String } + } +} +"#, + ) + .expect("failed to write resource-signature-mismatch source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("`Disk` does not match `Storage.read` contract")); + assert!(error.contains("expected `String` but found `Int`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_ambiguous_interface_reference_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_ambiguous_interface_reference_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/first.dag"), + "module sample.first\ninterface Storage { capability read { input { path: String } output { body: String } } }", + ) + .expect("failed to write first interface source"); + std::fs::write( + root.join("sample/second.dag"), + "module sample.second\ninterface Storage { capability read { input { path: String } output { body: String } } }", + ) + .expect("failed to write second interface source"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nservice FsStorage implements Storage { operation read(path: String) -> { body: String } }", + ) + .expect("failed to write main source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("ambiguous interface `Storage`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_ambiguous_resource_interface_reference_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_ambiguous_resource_interface_reference_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/first.dag"), + "module sample.first\ninterface Storage { capability read { input { path: String } output { body: String } } }", + ) + .expect("failed to write first interface source"); + std::fs::write( + root.join("sample/second.dag"), + "module sample.second\ninterface Storage { capability read { input { path: String } output { body: String } } }", + ) + .expect("failed to write second interface source"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nresource Disk implements Storage { capability read { input { path: String } output { body: String } } }", + ) + .expect("failed to write main source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("`Disk` references ambiguous interface `Storage`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_ambiguous_uses_resource_type_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_ambiguous_uses_resource_type_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/one.dag"), + "module sample.one\nresource SharedResource {}", + ) + .expect("failed to write first resource source"); + std::fs::write( + root.join("sample/two.dag"), + "module sample.two\nresource SharedResource {}", + ) + .expect("failed to write second resource source"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfunc run() -> { ok: Bool } uses fs: SharedResource { return { ok: true } }", + ) + .expect("failed to write main source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("ambiguous used resource type `SharedResource`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_ambiguous_provides_resource_type_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_ambiguous_provides_resource_type_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/one.dag"), + "module sample.one\nresource SharedResource {}", + ) + .expect("failed to write first resource source"); + std::fs::write( + root.join("sample/two.dag"), + "module sample.two\nresource SharedResource {}", + ) + .expect("failed to write second resource source"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfunc run() -> { ok: Bool } provides out: SharedResource { return { ok: true } }", + ) + .expect("failed to write main source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("ambiguous provided resource type `SharedResource`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_ambiguous_service_call_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_ambiguous_service_call_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/first.dag"), + r#"module sample.first +service SharedService { + operation read(path: String) -> { body: String } +}"#, + ) + .expect("failed to write first service source"); + std::fs::write( + root.join("sample/second.dag"), + r#"module sample.second +service SharedService { + operation read(path: String) -> { body: String } +}"#, + ) + .expect("failed to write second service source"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +func run(path: String) -> { body: String } { + let response = SharedService.read(path: path) + return { body: response.body } +}"#, + ) + .expect("failed to write main source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("ambiguous service call `SharedService.read`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn compile_directory_ambiguous_callable_target_fails_in_typecheck_stage() { + let root = std::env::temp_dir().join(format!( + "daglang_compile_ambiguous_callable_target_dir_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + std::fs::write( + root.join("sample/one.dag"), + "module sample.one\nfn render(value: String) -> String { value }", + ) + .expect("failed to write first callable source"); + std::fs::write( + root.join("sample/two.dag"), + "module sample.two\nfn render(value: String) -> String { value }", + ) + .expect("failed to write second callable source"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfn run() -> String { render(value: \"ok\") }", + ) + .expect("failed to write main source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + + let error = compile_from_context(&context).expect_err("compile should fail"); + assert_typecheck_stage_error(&error); + assert!(error.contains("ambiguous call target `render`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } +} diff --git a/core/daglang/daglang-cli/src/lib.rs b/core/daglang/daglang-cli/src/lib.rs index 626c2e4ca57..df698104003 100644 --- a/core/daglang/daglang-cli/src/lib.rs +++ b/core/daglang/daglang-cli/src/lib.rs @@ -1 +1,2 @@ +pub mod compile; pub mod pipeline; diff --git a/core/daglang/daglang-cli/src/main.rs b/core/daglang/daglang-cli/src/main.rs index 3727cb333a2..5e20e84c4ff 100644 --- a/core/daglang/daglang-cli/src/main.rs +++ b/core/daglang/daglang-cli/src/main.rs @@ -15,6 +15,9 @@ use std::path::{Component, Path, PathBuf}; +use daglang_cli::compile::{ + build_context, compile_from_context, render_expand, render_manifest, +}; use daglang_cli::pipeline::{build_pipeline_dag, run_pipeline, PipelineContext, PipelineStop}; fn main() { @@ -42,10 +45,16 @@ fn main() { println!("{}", dag.to_mermaid("daglang-compiler-pipeline")); } 3 => { - exit_unimplemented( - "viz ", - "file visualization is not implemented in Phase 0. Use `daglang viz --self` to inspect the compiler pipeline DAG.", - ); + let context = build_context(args.get(2)); + match compile_from_context(&context) { + Ok(output) => { + println!("{}", output.lowered_dag.to_mermaid("daglang-compiled")); + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } } _ => exit_usage("viz |viz --self"), } @@ -54,19 +63,31 @@ fn main() { if args.len() != 3 { exit_usage("expand "); } - exit_unimplemented( - "expand ", - "GraphIR expansion is not implemented in Phase 0.", - ); + let context = build_context(args.get(2)); + match compile_from_context(&context) { + Ok(output) => { + println!("{}", render_expand(&output.lowered_dag)); + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } } "manifest" => { if args.len() != 3 { exit_usage("manifest "); } - exit_unimplemented( - "manifest ", - "manifest derivation is not implemented in Phase 0.", - ); + let context = build_context(args.get(2)); + match compile_from_context(&context) { + Ok(output) => { + println!("{}", render_manifest(&output.derived)); + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } } "modules" => { if args.len() > 3 { @@ -126,13 +147,27 @@ fn main() { } } "compile" => { - if args.len() != 3 { - exit_usage("compile "); + if args.len() > 3 { + exit_usage("compile "); + } + let context = build_context(args.get(2)); + match compile_from_context(&context) { + Ok(output) => { + println!( + "Compiled {} module(s) to {} node(s), {} file(s) emitted.", + output.emitted.summary.module_count, + output.derived.manifest.total_nodes, + output.emitted.files.len() + ); + for file in &output.emitted.files { + println!(" - {}", file.path); + } + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } } - exit_unimplemented( - "compile ", - "full compilation pipeline is not implemented in Phase 0.", - ); } cmd => { eprintln!("Unknown command: {cmd}"); @@ -149,12 +184,6 @@ fn resolve_root(arg: Option<&String>) -> PathBuf { default_root_from_cwd(&cwd) } -fn exit_unimplemented(command: &str, detail: &str) -> ! { - eprintln!("command `{command}` is not implemented."); - eprintln!("{detail}"); - std::process::exit(2); -} - fn exit_usage(command: &str) -> ! { eprintln!("Usage: daglang {command}"); std::process::exit(1); diff --git a/core/daglang/daglang-cli/tests/cli_commands.rs b/core/daglang/daglang-cli/tests/cli_commands.rs index 11914160e84..cc1ea67e86d 100644 --- a/core/daglang/daglang-cli/tests/cli_commands.rs +++ b/core/daglang/daglang-cli/tests/cli_commands.rs @@ -28,6 +28,17 @@ fn unique_temp_dir(name: &str) -> PathBuf { std::env::temp_dir().join(format!("daglang_cli_{name}_{}_{}", std::process::id(), nanos)) } +fn assert_no_compile_stage_banners(stderr: &str) { + assert!( + !stderr.contains("typecheck errors"), + "unexpected typecheck-stage banner in non-compile command path: {stderr}" + ); + assert!( + !stderr.contains("lower error"), + "unexpected lower-stage banner in non-compile command path: {stderr}" + ); +} + fn unique_name(name: &str) -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -242,6 +253,8 @@ fn check_command_parses_full_dsl_corpus() { "check command failed: {}", String::from_utf8_lossy(&output.stderr) ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_compile_stage_banners(&stderr); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("OK: parsed 42 file(s)"), @@ -16101,6 +16114,8 @@ fn modules_command_prints_module_graph_summary() { "modules command failed: {}", String::from_utf8_lossy(&output.stderr) ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_compile_stage_banners(&stderr); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("Discovered modules:")); let reported_modules = reported_modules_sorted(&stdout); @@ -26587,6 +26602,8 @@ fn viz_self_renders_pipeline_mermaid() { "viz --self command failed: {}", String::from_utf8_lossy(&output.stderr) ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_compile_stage_banners(&stderr); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("flowchart TB")); assert!(stdout.contains("discover_files")); @@ -26671,6 +26688,7 @@ fn check_command_reports_file_line_col_for_broken_file() { assert!(!output.status.success(), "broken file should fail check"); let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_compile_stage_banners(&stderr); assert!( stderr.contains(":2:12:"), "expected file:line:col in stderr, got: {stderr}" @@ -27962,6 +27980,8 @@ fn modules_command_reports_graph_diagnostics_without_failing() { output.status.success(), "modules command should still succeed while reporting diagnostics" ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_compile_stage_banners(&stderr); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("Diagnostics:")); assert!(stdout.contains("unresolved import")); @@ -28134,6 +28154,8 @@ fn modules_command_reports_cycle_diagnostics_without_failing() { output.status.success(), "modules command should still succeed while reporting cycle diagnostics" ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_compile_stage_banners(&stderr); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("Diagnostics:")); assert!(stdout.contains("cyclic dependencies detected")); @@ -28231,6 +28253,8 @@ fn check_command_single_file_mode_ignores_sibling_broken_files() { "single-file check should succeed even with sibling broken file: {}", String::from_utf8_lossy(&output.stderr) ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_compile_stage_banners(&stderr); let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!(stdout, expected_check_success_stdout(1)); assert!( @@ -28263,6 +28287,7 @@ fn check_command_directory_mode_aggregates_multiple_file_diagnostics() { "directory check should fail when multiple files are invalid" ); let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_compile_stage_banners(&stderr); assert!(stderr.contains("broken_a.dag")); assert!(stderr.contains("broken_b.dag")); @@ -28499,6 +28524,8 @@ fn check_command_defaults_to_workspace_dsl_root() { "default check command should succeed: {}", String::from_utf8_lossy(&output.stderr) ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_compile_stage_banners(&stderr); assert!( String::from_utf8_lossy(&output.stdout).contains("OK: parsed 42 file(s)"), "default check should parse full DSL corpus" @@ -28584,6 +28611,8 @@ fn modules_command_defaults_to_workspace_dsl_root() { "default modules command should succeed: {}", String::from_utf8_lossy(&output.stderr) ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_compile_stage_banners(&stderr); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("Discovered modules:")); let reported_modules = reported_modules_sorted(&stdout); @@ -29733,8 +29762,8 @@ fn viz_without_args_exits_nonzero_with_usage_message() { } #[test] -fn placeholder_commands_without_required_target_exit_with_usage_message() { - for command in ["expand", "manifest", "compile"] { +fn expand_and_manifest_without_required_target_exit_with_usage_message() { + for command in ["expand", "manifest"] { let output = Command::new(daglang_bin()) .arg(command) .current_dir(workspace_root()) @@ -29758,8 +29787,8 @@ fn placeholder_commands_without_required_target_exit_with_usage_message() { } #[test] -fn placeholder_commands_with_extra_args_exit_with_usage_message() { - for command in ["expand", "manifest", "compile"] { +fn expand_and_manifest_with_extra_args_exit_with_usage_message() { + for command in ["expand", "manifest"] { let output = Command::new(daglang_bin()) .arg(command) .arg("dsl/tools/makegen.dag") @@ -29785,7 +29814,7 @@ fn placeholder_commands_with_extra_args_exit_with_usage_message() { } #[test] -fn placeholder_commands_exit_nonzero_with_unimplemented_message() { +fn compile_family_commands_execute_real_pipeline_paths() { for command in ["expand", "manifest", "compile"] { let output = Command::new(daglang_bin()) .arg(command) @@ -29794,47 +29823,42 @@ fn placeholder_commands_exit_nonzero_with_unimplemented_message() { .output() .unwrap_or_else(|err| panic!("failed to run {command}: {err}")); assert!( - !output.status.success(), - "{command} placeholder command should fail until implemented" - ); - assert_eq!( - output.status.code(), - Some(2), - "{command} placeholder command should use unimplemented exit code" + output.status.success(), + "{command} should execute successfully for makegen fixture" ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("not implemented"), - "{command} placeholder should explicitly report unimplemented status" + !stderr.contains("TODO"), + "{command} should no longer emit TODO placeholder output: {stderr}" + ); + assert!( + !output.stdout.is_empty(), + "{command} should emit meaningful stdout output" ); } } #[test] -fn viz_without_self_exits_nonzero_with_unimplemented_message() { +fn viz_without_self_emits_compiled_mermaid_graph() { let output = Command::new(daglang_bin()) .arg("viz") .arg("dsl/tools/makegen.dag") .current_dir(workspace_root()) .output() - .expect("failed to run daglang viz placeholder mode"); + .expect("failed to run daglang viz"); assert!( - !output.status.success(), - "viz placeholder mode should fail until file visualization ships" - ); - assert_eq!( - output.status.code(), - Some(2), - "viz placeholder mode should use unimplemented exit code" + output.status.success(), + "viz should compile and render mermaid output" ); - let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stderr.contains("not implemented"), - "viz placeholder mode should surface unimplemented guidance" + stdout.contains("flowchart TB"), + "viz mermaid output should include flowchart header: {stdout}" ); + let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("viz --self"), - "viz placeholder mode should include available Phase 0 guidance" + !stderr.contains("TODO"), + "viz should not emit TODO placeholder output: {stderr}" ); } diff --git a/core/daglang/daglang-cli/tests/compile_commands.rs b/core/daglang/daglang-cli/tests/compile_commands.rs new file mode 100644 index 00000000000..eae838de13f --- /dev/null +++ b/core/daglang/daglang-cli/tests/compile_commands.rs @@ -0,0 +1,13991 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..") +} + +fn daglang_bin() -> &'static str { + env!("CARGO_BIN_EXE_daglang") +} + +fn makegen_file() -> PathBuf { + workspace_root().join("dsl/tools/makegen.dag") +} + +fn unique_temp_file(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "daglang_cli_compile_cmd_{name}_{}_{}.dag", + std::process::id(), + nanos + )) +} + +fn unique_temp_dir(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "daglang_cli_compile_cmd_dir_{name}_{}_{}", + std::process::id(), + nanos + )) +} + +fn write_minimal_directory_compile_fixture(root: &Path) { + std::fs::create_dir_all(root.join("dsl/sample")).expect("failed to create dsl fixture tree"); + std::fs::write( + root.join("dsl/sample/main.dag"), + "module sample.main\nfn run() -> Unit { }", + ) + .expect("failed to write minimal compile fixture"); +} + +fn assert_typecheck_stage_failure(stderr: &str) { + assert!(stderr.contains("typecheck errors")); + assert!( + !stderr.contains("lower error"), + "expected failure to remain in typecheck stage: {stderr}" + ); +} + +fn assert_lower_stage_failure(stderr: &str) { + assert!(stderr.contains("lower error")); + assert!( + !stderr.contains("typecheck errors"), + "expected failure to remain in lowering stage: {stderr}" + ); +} + +fn assert_no_stage_failures(stderr: &str) { + assert!( + !stderr.contains("typecheck errors"), + "did not expect typecheck-stage banner in successful path: {stderr}" + ); + assert!( + !stderr.contains("lower error"), + "did not expect lowering-stage banner in successful path: {stderr}" + ); +} + +#[test] +fn compile_command_emits_summary_for_single_file() { + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(makegen_file()) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + output.status.success(), + "compile command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Compiled 1 module(s)")); + assert!(stdout.contains("target/generated/rust/main.rs")); +} + +#[cfg(unix)] +#[test] +fn compile_command_accepts_symlink_single_file_target() { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("compile_symlink_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let real = root.join("real.dag"); + let link = root.join("link.dag"); + std::fs::write(&real, "module sample.real\nfn ok() -> Unit {}") + .expect("failed to write real source"); + symlink(&real, &link).expect("failed to create symlinked target"); + + let real_output = Command::new(daglang_bin()) + .arg("compile") + .arg(&real) + .current_dir(&root) + .output() + .expect("failed to run daglang compile on real target"); + assert!( + real_output.status.success(), + "compile should succeed for real single-file target: {}", + String::from_utf8_lossy(&real_output.stderr) + ); + + let link_output = Command::new(daglang_bin()) + .arg("compile") + .arg(&link) + .current_dir(&root) + .output() + .expect("failed to run daglang compile on symlinked target"); + assert!( + link_output.status.success(), + "compile should succeed for symlinked single-file target: {}", + String::from_utf8_lossy(&link_output.stderr) + ); + + assert_eq!( + real_output.stdout, link_output.stdout, + "real and symlink single-file compile stdout should match" + ); + assert_eq!( + real_output.stderr, link_output.stderr, + "real and symlink single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&link_output.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn compile_command_accepts_symlink_root_directory() { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("compile_symlink_root_directory"); + let real_root = root.join("real_root"); + std::fs::create_dir_all(real_root.join("sample")).expect("failed to create real root fixture"); + std::fs::write( + real_root.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit { }", + ) + .expect("failed to write real root dag"); + let link_root = root.join("link_root"); + symlink(&real_root, &link_root).expect("failed to create symlinked root"); + + let real_output = Command::new(daglang_bin()) + .arg("compile") + .arg(&real_root) + .current_dir(&root) + .output() + .expect("failed to run daglang compile on real root"); + assert!( + real_output.status.success(), + "compile should succeed for real root directory: {}", + String::from_utf8_lossy(&real_output.stderr) + ); + + let link_output = Command::new(daglang_bin()) + .arg("compile") + .arg(&link_root) + .current_dir(&root) + .output() + .expect("failed to run daglang compile on symlinked root"); + assert!( + link_output.status.success(), + "compile should succeed for symlinked root directory: {}", + String::from_utf8_lossy(&link_output.stderr) + ); + + assert_eq!( + real_output.stdout, link_output.stdout, + "real and symlink root compile stdout should match" + ); + assert_eq!( + real_output.stderr, link_output.stderr, + "real and symlink root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&link_output.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn compile_command_dangling_symlink_single_file_target_exits_nonzero() { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("compile_dangling_symlink_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let dangling_target = root.join("missing.dag"); + let dangling_link = root.join("broken.dag"); + symlink(&dangling_target, &dangling_link).expect("failed to create dangling symlink target"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&dangling_link) + .current_dir(&root) + .output() + .expect("failed to run daglang compile on dangling symlink target"); + assert!( + !output.status.success(), + "compile should fail for dangling symlink single-file target" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("failed to read")); + assert!( + stderr.contains("broken.dag"), + "dangling symlink compile failure should include offending path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn compile_command_relative_and_absolute_dangling_symlink_targets_are_equivalent() { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("compile_relative_absolute_dangling_symlink_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let dangling_target = root.join("missing.dag"); + let dangling_link = root.join("broken.dag"); + symlink(&dangling_target, &dangling_link).expect("failed to create dangling symlink target"); + + let relative = Command::new(daglang_bin()) + .arg("compile") + .arg("broken.dag") + .current_dir(&root) + .output() + .expect("failed to run relative dangling-target daglang compile"); + assert!( + !relative.status.success(), + "relative dangling-target compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&dangling_link) + .current_dir(&root) + .output() + .expect("failed to run absolute dangling-target daglang compile"); + assert!( + !absolute.status.success(), + "absolute dangling-target compile should fail" + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute dangling-target compile stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute dangling-target compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&relative.stderr); + assert!( + stderr.contains(&format!( + "failed to read {}", + dangling_link.display() + )), + "dangling-target compile diagnostics should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_relative_and_absolute_missing_roots_are_equivalent() { + let root = unique_temp_dir("compile_relative_absolute_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let relative = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run relative missing-root compile"); + assert!( + !relative.status.success(), + "relative missing-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(&root) + .output() + .expect("failed to run absolute missing-root compile"); + assert!( + !absolute.status.success(), + "absolute missing-root compile should fail" + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute missing-root compile stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&relative.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "missing-root diagnostic should contain normalized absolute path: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_relative_and_absolute_non_directory_roots_are_equivalent() { + let root = unique_temp_dir("compile_relative_absolute_non_directory_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let file_path = root.join("input.txt"); + std::fs::write(&file_path, "not a directory").expect("failed to write non-directory root"); + + let relative = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run relative non-directory-root compile"); + assert!( + !relative.status.success(), + "relative non-directory-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&file_path) + .current_dir(&root) + .output() + .expect("failed to run absolute non-directory-root compile"); + assert!( + !absolute.status.success(), + "absolute non-directory-root compile should fail" + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute non-directory-root compile stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&relative.stderr); + assert!( + stderr.contains(&file_path.display().to_string()), + "non-directory-root diagnostic should contain normalized absolute path: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_segment_missing_root_is_normalized_and_equivalent() { + let root = unique_temp_dir("compile_parent_segment_missing_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let parent_segment = Command::new(daglang_bin()) + .arg("compile") + .arg("../missing_root") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-segment missing-root compile"); + assert!( + !parent_segment.status.success(), + "parent-segment missing-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing-root compile"); + assert!( + !absolute.status.success(), + "absolute missing-root compile should fail" + ); + + assert_eq!( + parent_segment.stdout, absolute.stdout, + "parent-segment and absolute missing-root compile stdout should match" + ); + assert_eq!( + parent_segment.stderr, absolute.stderr, + "parent-segment and absolute missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_segment.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "parent-segment missing-root diagnostic should normalize to absolute path: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_segment_trailing_slash_missing_root_is_normalized_and_equivalent() { + let root = unique_temp_dir("compile_parent_segment_trailing_missing_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let parent_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("../missing_root/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-trailing missing-root compile"); + assert!( + !parent_trailing.status.success(), + "parent-trailing missing-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing-root compile"); + assert!( + !absolute.status.success(), + "absolute missing-root compile should fail" + ); + + assert_eq!( + parent_trailing.stdout, absolute.stdout, + "parent-trailing and absolute missing-root compile stdout should match" + ); + assert_eq!( + parent_trailing.stderr, absolute.stderr, + "parent-trailing and absolute missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_trailing.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "parent-trailing missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_double_separator_missing_root_is_normalized_and_equivalent() { + let root = unique_temp_dir("compile_parent_double_separator_missing_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let parent_double = Command::new(daglang_bin()) + .arg("compile") + .arg("..//missing_root") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-double missing-root compile"); + assert!( + !parent_double.status.success(), + "parent-double missing-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing-root compile"); + assert!( + !absolute.status.success(), + "absolute missing-root compile should fail" + ); + + assert_eq!( + parent_double.stdout, absolute.stdout, + "parent-double and absolute missing-root compile stdout should match" + ); + assert_eq!( + parent_double.stderr, absolute.stderr, + "parent-double and absolute missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_double.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "parent-double missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_double_separator_trailing_slash_missing_root_is_normalized_and_equivalent( +) { + let root = unique_temp_dir("compile_parent_double_trailing_missing_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let parent_double_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("..//missing_root/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-double-trailing missing-root compile"); + assert!( + !parent_double_trailing.status.success(), + "parent-double-trailing missing-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing-root compile"); + assert!( + !absolute.status.success(), + "absolute missing-root compile should fail" + ); + + assert_eq!( + parent_double_trailing.stdout, absolute.stdout, + "parent-double-trailing and absolute missing-root compile stdout should match" + ); + assert_eq!( + parent_double_trailing.stderr, absolute.stderr, + "parent-double-trailing and absolute missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_double_trailing.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "parent-double-trailing missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_segment_non_directory_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_segment_non_directory_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let parent_segment = Command::new(daglang_bin()) + .arg("compile") + .arg("../input.txt") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-segment non-directory-root compile"); + assert!( + !parent_segment.status.success(), + "parent-segment non-directory-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&root_file) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute non-directory-root compile"); + assert!( + !absolute.status.success(), + "absolute non-directory-root compile should fail" + ); + + assert_eq!( + parent_segment.stdout, absolute.stdout, + "parent-segment and absolute non-directory-root compile stdout should match" + ); + assert_eq!( + parent_segment.stderr, absolute.stderr, + "parent-segment and absolute non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_segment.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "parent-segment non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_segment_trailing_slash_non_directory_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_segment_trailing_non_directory_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let parent_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("../input.txt/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-trailing non-directory-root compile"); + assert!( + !parent_trailing.status.success(), + "parent-trailing non-directory-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&root_file) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute non-directory-root compile"); + assert!( + !absolute.status.success(), + "absolute non-directory-root compile should fail" + ); + + assert_eq!( + parent_trailing.stdout, absolute.stdout, + "parent-trailing and absolute non-directory-root compile stdout should match" + ); + assert_eq!( + parent_trailing.stderr, absolute.stderr, + "parent-trailing and absolute non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_trailing.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "parent-trailing non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_double_separator_non_directory_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_double_non_directory_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let parent_double = Command::new(daglang_bin()) + .arg("compile") + .arg("..//input.txt") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-double non-directory-root compile"); + assert!( + !parent_double.status.success(), + "parent-double non-directory-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&root_file) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute non-directory-root compile"); + assert!( + !absolute.status.success(), + "absolute non-directory-root compile should fail" + ); + + assert_eq!( + parent_double.stdout, absolute.stdout, + "parent-double and absolute non-directory-root compile stdout should match" + ); + assert_eq!( + parent_double.stderr, absolute.stderr, + "parent-double and absolute non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_double.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "parent-double non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_double_separator_trailing_slash_non_directory_root_matches_absolute_output() +{ + let root = unique_temp_dir("compile_parent_double_trailing_non_directory_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let parent_double_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("..//input.txt/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-double-trailing non-directory-root compile"); + assert!( + !parent_double_trailing.status.success(), + "parent-double-trailing non-directory-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&root_file) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute non-directory-root compile"); + assert!( + !absolute.status.success(), + "absolute non-directory-root compile should fail" + ); + + assert_eq!( + parent_double_trailing.stdout, absolute.stdout, + "parent-double-trailing and absolute non-directory-root compile stdout should match" + ); + assert_eq!( + parent_double_trailing.stderr, absolute.stderr, + "parent-double-trailing and absolute non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_double_trailing.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "parent-double-trailing non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let curdir = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing_root") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment missing-root compile"); + assert!( + !curdir.status.success(), + "curdir-segment missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, curdir.stdout, + "plain and curdir missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir.stderr, + "plain and curdir missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "curdir-segment missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_trailing_slash_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_trailing_slash_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let curdir_segment_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing_root/") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-trailing missing-root compile"); + assert!( + !curdir_segment_trailing.status.success(), + "curdir-segment-trailing missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_trailing.stdout, + "plain and curdir-segment-trailing missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_trailing.stderr, + "plain and curdir-segment-trailing missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "curdir-segment-trailing missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_trailing_slash_missing_root_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_trailing_slash_missing_root", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let dot_double_separator_curdir_segment_trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing_root/") + .current_dir(&root) + .output() + .expect( + "failed to run dot-double-separator-curdir-segment-trailing-slash missing-root compile", + ); + assert!( + !dot_double_separator_curdir_segment_trailing_slash.status.success(), + "dot-double-separator-curdir-segment-trailing-slash missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_trailing_slash.stdout, + "plain and dot-double-separator-curdir-segment-trailing-slash missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_trailing_slash.stderr, + "plain and dot-double-separator-curdir-segment-trailing-slash missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "dot-double-separator-curdir-segment-trailing-slash missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_suffix_missing_root_matches_plain_relative_output( +) { + let root = + unique_temp_dir("compile_dot_double_separator_curdir_segment_suffix_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let dot_double_separator_curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing_root/./.") + .current_dir(&root) + .output() + .expect( + "failed to run dot-double-separator-curdir-segment-suffix missing-root compile", + ); + assert!( + !dot_double_separator_curdir_segment_suffix.status.success(), + "dot-double-separator-curdir-segment-suffix missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_segment_suffix.stdout, + "plain and dot-double-separator-curdir-segment-suffix missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_segment_suffix.stderr, + "plain and dot-double-separator-curdir-segment-suffix missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "dot-double-separator-curdir-segment-suffix missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_suffix_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_curdir_suffix_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let dot_double_separator_curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing_root/.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-suffix missing-root compile"); + assert!( + !dot_double_separator_curdir_suffix.status.success(), + "dot-double-separator-curdir-suffix missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_suffix.stdout, + "plain and dot-double-separator-curdir-suffix missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_suffix.stderr, + "plain and dot-double-separator-curdir-suffix missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "dot-double-separator-curdir-suffix missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_suffix_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_suffix_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing_root/.") + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix missing-root compile"); + assert!( + !curdir_suffix.status.success(), + "curdir-suffix missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "curdir-suffix missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_suffix_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_suffix_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing_root/./.") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-suffix missing-root compile"); + assert!( + !curdir_segment_suffix.status.success(), + "curdir-segment-suffix missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_suffix.stdout, + "plain and curdir-segment-suffix missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_suffix.stderr, + "plain and curdir-segment-suffix missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "curdir-segment-suffix missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_suffix_double_separator_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_suffix_double_separator_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing_root//.") + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix-double-separator missing-root compile"); + assert!( + !curdir_suffix_double_separator.status.success(), + "curdir-suffix-double-separator missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_suffix_double_separator.stdout, + "plain and curdir-suffix-double-separator missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix_double_separator.stderr, + "plain and curdir-suffix-double-separator missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "curdir-suffix-double-separator missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_double_separator_suffix_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_double_separator_suffix_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing_root//./.") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-double-separator-suffix missing-root compile"); + assert!( + !curdir_segment_double_separator_suffix.status.success(), + "curdir-segment-double-separator-suffix missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_double_separator_suffix.stdout, + "plain and curdir-segment-double-separator-suffix missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_double_separator_suffix.stderr, + "plain and curdir-segment-double-separator-suffix missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "curdir-segment-double-separator-suffix missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_double_separator_suffix_missing_root_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_double_separator_suffix_missing_root", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let dot_double_separator_curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing_root//./.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-double-separator-suffix missing-root compile"); + assert!( + !dot_double_separator_curdir_segment_double_separator_suffix + .status + .success(), + "dot-double-separator-curdir-segment-double-separator-suffix missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_double_separator_suffix.stdout, + "plain and dot-double-separator-curdir-segment-double-separator-suffix missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_double_separator_suffix.stderr, + "plain and dot-double-separator-curdir-segment-double-separator-suffix missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "dot-double-separator-curdir-segment-double-separator-suffix missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_double_separator_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_double_separator_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing_root//") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-double-separator missing-root compile"); + assert!( + !curdir_segment_double_separator.status.success(), + "curdir-segment-double-separator missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_double_separator.stdout, + "plain and curdir-segment-double-separator missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_double_separator.stderr, + "plain and curdir-segment-double-separator missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "curdir-segment-double-separator missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_double_separator_missing_root_matches_plain_relative_output( +) { + let root = + unique_temp_dir("compile_dot_double_separator_curdir_segment_double_separator_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let dot_double_separator_curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing_root//") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-double-separator missing-root compile"); + assert!( + !dot_double_separator_curdir_segment_double_separator.status.success(), + "dot-double-separator-curdir-segment-double-separator missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_double_separator.stdout, + "plain and dot-double-separator-curdir-segment-double-separator missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_double_separator.stderr, + "plain and dot-double-separator-curdir-segment-double-separator missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "dot-double-separator-curdir-segment-double-separator missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_double_separator_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_double_separator_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let curdir_double = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing_root") + .current_dir(&root) + .output() + .expect("failed to run curdir-double-separator missing-root compile"); + assert!( + !curdir_double.status.success(), + "curdir-double-separator missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_double.stdout, + "plain and curdir-double missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_double.stderr, + "plain and curdir-double missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "curdir-double missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_double_separator_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_double_separator_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let double_sep = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root//") + .current_dir(&root) + .output() + .expect("failed to run double-separator missing-root compile"); + assert!( + !double_sep.status.success(), + "double-separator missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, double_sep.stdout, + "plain and double-separator missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, double_sep.stderr, + "plain and double-separator missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "double-separator missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_trailing_slash_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_trailing_slash_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root/") + .current_dir(&root) + .output() + .expect("failed to run trailing-slash missing-root compile"); + assert!( + !trailing_slash.status.success(), + "trailing-slash missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, trailing_slash.stdout, + "plain and trailing-slash missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, trailing_slash.stderr, + "plain and trailing-slash missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "trailing-slash missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_relative_and_absolute_missing_single_file_targets_are_equivalent() { + let root = unique_temp_dir("compile_relative_absolute_missing_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let relative = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run relative missing single-file compile"); + assert!( + !relative.status.success(), + "relative missing single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(&root) + .output() + .expect("failed to run absolute missing single-file compile"); + assert!( + !absolute.status.success(), + "absolute missing single-file compile should fail" + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute missing single-file compile stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&relative.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "missing single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_trailing_slash_missing_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_trailing_slash_missing_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag/") + .current_dir(&root) + .output() + .expect("failed to run trailing-slash missing single-file compile"); + assert!( + !trailing_slash.status.success(), + "trailing-slash missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, trailing_slash.stdout, + "plain and trailing-slash missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, trailing_slash.stderr, + "plain and trailing-slash missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "trailing-slash missing single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_missing_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_missing_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let curdir = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing.dag") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment missing single-file compile"); + assert!( + !curdir.status.success(), + "curdir-segment missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir.stdout, + "plain and curdir missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir.stderr, + "plain and curdir missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "curdir-segment missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_trailing_slash_missing_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_curdir_segment_trailing_missing_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let curdir_segment_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing.dag/") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-trailing missing single-file compile"); + assert!( + !curdir_segment_trailing.status.success(), + "curdir-segment-trailing missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_trailing.stdout, + "plain and curdir-segment-trailing missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_trailing.stderr, + "plain and curdir-segment-trailing missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "curdir-segment-trailing missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_trailing_slash_missing_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_trailing_missing_single_file_target", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let dot_double_separator_curdir_segment_trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing.dag/") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-trailing-slash missing single-file compile"); + assert!( + !dot_double_separator_curdir_segment_trailing_slash.status.success(), + "dot-double-separator-curdir-segment-trailing-slash missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_trailing_slash.stdout, + "plain and dot-double-separator-curdir-segment-trailing-slash missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_trailing_slash.stderr, + "plain and dot-double-separator-curdir-segment-trailing-slash missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "dot-double-separator-curdir-segment-trailing-slash missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_suffix_missing_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_suffix_missing_single_file_target", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let dot_double_separator_curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing.dag/./.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-suffix missing single-file compile"); + assert!( + !dot_double_separator_curdir_segment_suffix.status.success(), + "dot-double-separator-curdir-segment-suffix missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_segment_suffix.stdout, + "plain and dot-double-separator-curdir-segment-suffix missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_segment_suffix.stderr, + "plain and dot-double-separator-curdir-segment-suffix missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "dot-double-separator-curdir-segment-suffix missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_suffix_missing_single_file_target_matches_plain_relative_output( +) { + let root = + unique_temp_dir("compile_dot_double_separator_curdir_suffix_missing_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let dot_double_separator_curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing.dag/.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-suffix missing single-file compile"); + assert!( + !dot_double_separator_curdir_suffix.status.success(), + "dot-double-separator-curdir-suffix missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_suffix.stdout, + "plain and dot-double-separator-curdir-suffix missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_suffix.stderr, + "plain and dot-double-separator-curdir-suffix missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "dot-double-separator-curdir-suffix missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_suffix_missing_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_suffix_missing_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing.dag/.") + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix missing single-file compile"); + assert!( + !curdir_suffix.status.success(), + "curdir-suffix missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "curdir-suffix missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_suffix_missing_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_suffix_missing_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing.dag/./.") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-suffix missing single-file compile"); + assert!( + !curdir_segment_suffix.status.success(), + "curdir-segment-suffix missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_suffix.stdout, + "plain and curdir-segment-suffix missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_suffix.stderr, + "plain and curdir-segment-suffix missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "curdir-segment-suffix missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_suffix_double_separator_missing_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_curdir_suffix_double_separator_missing_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing.dag//.") + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix-double-separator missing single-file compile"); + assert!( + !curdir_suffix_double_separator.status.success(), + "curdir-suffix-double-separator missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_suffix_double_separator.stdout, + "plain and curdir-suffix-double-separator missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix_double_separator.stderr, + "plain and curdir-suffix-double-separator missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "curdir-suffix-double-separator missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_double_separator_suffix_missing_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_curdir_segment_double_separator_suffix_missing_single_file_target", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing.dag//./.") + .current_dir(&root) + .output() + .expect( + "failed to run curdir-segment-double-separator-suffix missing single-file compile", + ); + assert!( + !curdir_segment_double_separator_suffix.status.success(), + "curdir-segment-double-separator-suffix missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_double_separator_suffix.stdout, + "plain and curdir-segment-double-separator-suffix missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_double_separator_suffix.stderr, + "plain and curdir-segment-double-separator-suffix missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "curdir-segment-double-separator-suffix missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_double_separator_suffix_missing_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_double_separator_suffix_missing_single_file_target", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let dot_double_separator_curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing.dag//./.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-double-separator-suffix missing single-file compile"); + assert!( + !dot_double_separator_curdir_segment_double_separator_suffix + .status + .success(), + "dot-double-separator-curdir-segment-double-separator-suffix missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_double_separator_suffix.stdout, + "plain and dot-double-separator-curdir-segment-double-separator-suffix missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_double_separator_suffix.stderr, + "plain and dot-double-separator-curdir-segment-double-separator-suffix missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "dot-double-separator-curdir-segment-double-separator-suffix missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_double_separator_missing_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_curdir_segment_double_separator_missing_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg("./missing.dag//") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-double-separator missing single-file compile"); + assert!( + !curdir_segment_double_separator.status.success(), + "curdir-segment-double-separator missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_double_separator.stdout, + "plain and curdir-segment-double-separator missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_double_separator.stderr, + "plain and curdir-segment-double-separator missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "curdir-segment-double-separator missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_double_separator_missing_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_double_separator_missing_single_file_target", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let dot_double_separator_curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing.dag//") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-double-separator missing single-file compile"); + assert!( + !dot_double_separator_curdir_segment_double_separator.status.success(), + "dot-double-separator-curdir-segment-double-separator missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_double_separator.stdout, + "plain and dot-double-separator-curdir-segment-double-separator missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_double_separator.stderr, + "plain and dot-double-separator-curdir-segment-double-separator missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "dot-double-separator-curdir-segment-double-separator missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_relative_and_absolute_invalid_single_file_targets_are_equivalent() { + let root = unique_temp_dir("compile_relative_absolute_invalid_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let relative = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run relative invalid single-file compile"); + assert!( + !relative.status.success(), + "relative invalid single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&invalid_target) + .current_dir(&root) + .output() + .expect("failed to run absolute invalid single-file compile"); + assert!( + !absolute.status.success(), + "absolute invalid single-file compile should fail" + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute invalid single-file compile stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&relative.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_trailing_slash_invalid_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_trailing_invalid_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag/") + .current_dir(&root) + .output() + .expect("failed to run trailing invalid single-file compile"); + assert!( + !trailing_slash.status.success(), + "trailing invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, trailing_slash.stdout, + "plain and trailing invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, trailing_slash.stderr, + "plain and trailing invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "trailing invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_invalid_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_invalid_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let curdir = Command::new(daglang_bin()) + .arg("compile") + .arg("./invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run curdir invalid single-file compile"); + assert!( + !curdir.status.success(), + "curdir invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir.stdout, + "plain and curdir invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir.stderr, + "plain and curdir invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "curdir invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_trailing_slash_invalid_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_curdir_segment_trailing_invalid_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let curdir_segment_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("./invalid.dag/") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-trailing invalid single-file compile"); + assert!( + !curdir_segment_trailing.status.success(), + "curdir-segment-trailing invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_trailing.stdout, + "plain and curdir-segment-trailing invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_trailing.stderr, + "plain and curdir-segment-trailing invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "curdir-segment-trailing invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_trailing_slash_invalid_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_trailing_invalid_single_file_target", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let dot_double_separator_curdir_segment_trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg(".//invalid.dag/") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-trailing-slash invalid single-file compile"); + assert!( + !dot_double_separator_curdir_segment_trailing_slash.status.success(), + "dot-double-separator-curdir-segment-trailing-slash invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_trailing_slash.stdout, + "plain and dot-double-separator-curdir-segment-trailing-slash invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_trailing_slash.stderr, + "plain and dot-double-separator-curdir-segment-trailing-slash invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "dot-double-separator-curdir-segment-trailing-slash invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_suffix_invalid_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_suffix_invalid_single_file_target", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let dot_double_separator_curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//invalid.dag/./.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-suffix invalid single-file compile"); + assert!( + !dot_double_separator_curdir_segment_suffix.status.success(), + "dot-double-separator-curdir-segment-suffix invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_segment_suffix.stdout, + "plain and dot-double-separator-curdir-segment-suffix invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_segment_suffix.stderr, + "plain and dot-double-separator-curdir-segment-suffix invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "dot-double-separator-curdir-segment-suffix invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_suffix_invalid_single_file_target_matches_plain_relative_output( +) { + let root = + unique_temp_dir("compile_dot_double_separator_curdir_suffix_invalid_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let dot_double_separator_curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//invalid.dag/.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-suffix invalid single-file compile"); + assert!( + !dot_double_separator_curdir_suffix.status.success(), + "dot-double-separator-curdir-suffix invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_suffix.stdout, + "plain and dot-double-separator-curdir-suffix invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_suffix.stderr, + "plain and dot-double-separator-curdir-suffix invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "dot-double-separator-curdir-suffix invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_suffix_invalid_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_suffix_invalid_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./invalid.dag/.") + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix invalid single-file compile"); + assert!( + !curdir_suffix.status.success(), + "curdir-suffix invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "curdir-suffix invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_suffix_invalid_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_suffix_invalid_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./invalid.dag/./.") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-suffix invalid single-file compile"); + assert!( + !curdir_segment_suffix.status.success(), + "curdir-segment-suffix invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_suffix.stdout, + "plain and curdir-segment-suffix invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_suffix.stderr, + "plain and curdir-segment-suffix invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "curdir-segment-suffix invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_suffix_double_separator_invalid_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_curdir_suffix_double_separator_invalid_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg("./invalid.dag//.") + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix-double-separator invalid single-file compile"); + assert!( + !curdir_suffix_double_separator.status.success(), + "curdir-suffix-double-separator invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_suffix_double_separator.stdout, + "plain and curdir-suffix-double-separator invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix_double_separator.stderr, + "plain and curdir-suffix-double-separator invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "curdir-suffix-double-separator invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_double_separator_suffix_invalid_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_curdir_segment_double_separator_suffix_invalid_single_file_target", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./invalid.dag//./.") + .current_dir(&root) + .output() + .expect( + "failed to run curdir-segment-double-separator-suffix invalid single-file compile", + ); + assert!( + !curdir_segment_double_separator_suffix.status.success(), + "curdir-segment-double-separator-suffix invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_double_separator_suffix.stdout, + "plain and curdir-segment-double-separator-suffix invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_double_separator_suffix.stderr, + "plain and curdir-segment-double-separator-suffix invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "curdir-segment-double-separator-suffix invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_double_separator_suffix_invalid_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_double_separator_suffix_invalid_single_file_target", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let dot_double_separator_curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//invalid.dag//./.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-double-separator-suffix invalid single-file compile"); + assert!( + !dot_double_separator_curdir_segment_double_separator_suffix + .status + .success(), + "dot-double-separator-curdir-segment-double-separator-suffix invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_double_separator_suffix.stdout, + "plain and dot-double-separator-curdir-segment-double-separator-suffix invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_double_separator_suffix.stderr, + "plain and dot-double-separator-curdir-segment-double-separator-suffix invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "dot-double-separator-curdir-segment-double-separator-suffix invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_double_separator_invalid_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_curdir_segment_double_separator_invalid_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg("./invalid.dag//") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-double-separator invalid single-file compile"); + assert!( + !curdir_segment_double_separator.status.success(), + "curdir-segment-double-separator invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_double_separator.stdout, + "plain and curdir-segment-double-separator invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_double_separator.stderr, + "plain and curdir-segment-double-separator invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "curdir-segment-double-separator invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_double_separator_invalid_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_double_separator_invalid_single_file_target", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let dot_double_separator_curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//invalid.dag//") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-double-separator invalid single-file compile"); + assert!( + !dot_double_separator_curdir_segment_double_separator.status.success(), + "dot-double-separator-curdir-segment-double-separator invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_double_separator.stdout, + "plain and dot-double-separator-curdir-segment-double-separator invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_double_separator.stderr, + "plain and dot-double-separator-curdir-segment-double-separator invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "dot-double-separator-curdir-segment-double-separator invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_double_separator_invalid_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_curdir_double_invalid_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let curdir_double = Command::new(daglang_bin()) + .arg("compile") + .arg(".//invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run curdir-double invalid single-file compile"); + assert!( + !curdir_double.status.success(), + "curdir-double invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_double.stdout, + "plain and curdir-double invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_double.stderr, + "plain and curdir-double invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "curdir-double invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_double_separator_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_double_separator_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_double = Command::new(daglang_bin()) + .arg("compile") + .arg(".//sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run curdir-double single-file compile"); + assert!( + curdir_double.status.success(), + "curdir-double single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_double.stderr) + ); + + assert_eq!( + plain.stdout, curdir_double.stdout, + "plain and curdir-double single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_double.stderr, + "plain and curdir-double single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_segment_missing_single_file_target_is_normalized_and_equivalent() { + let root = unique_temp_dir("compile_parent_segment_missing_single_file_target"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let parent_segment = Command::new(daglang_bin()) + .arg("compile") + .arg("../missing.dag") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-segment missing single-file compile"); + assert!( + !parent_segment.status.success(), + "parent-segment missing single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing single-file compile"); + assert!( + !absolute.status.success(), + "absolute missing single-file compile should fail" + ); + + assert_eq!( + parent_segment.stdout, absolute.stdout, + "parent-segment and absolute missing single-file compile stdout should match" + ); + assert_eq!( + parent_segment.stderr, absolute.stderr, + "parent-segment and absolute missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_segment.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "parent-segment missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_segment_trailing_slash_missing_single_file_target_is_normalized_and_equivalent( +) { + let root = unique_temp_dir("compile_parent_segment_trailing_missing_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let parent_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("../missing.dag/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-trailing missing single-file compile"); + assert!( + !parent_trailing.status.success(), + "parent-trailing missing single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing single-file compile"); + assert!( + !absolute.status.success(), + "absolute missing single-file compile should fail" + ); + + assert_eq!( + parent_trailing.stdout, absolute.stdout, + "parent-trailing and absolute missing single-file compile stdout should match" + ); + assert_eq!( + parent_trailing.stderr, absolute.stderr, + "parent-trailing and absolute missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_trailing.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "parent-trailing missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_double_separator_missing_single_file_target_is_normalized_and_equivalent() { + let root = unique_temp_dir("compile_parent_double_missing_single_file_target"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let parent_double = Command::new(daglang_bin()) + .arg("compile") + .arg("..//missing.dag") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-double missing single-file compile"); + assert!( + !parent_double.status.success(), + "parent-double missing single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing single-file compile"); + assert!( + !absolute.status.success(), + "absolute missing single-file compile should fail" + ); + + assert_eq!( + parent_double.stdout, absolute.stdout, + "parent-double and absolute missing single-file compile stdout should match" + ); + assert_eq!( + parent_double.stderr, absolute.stderr, + "parent-double and absolute missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_double.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "parent-double missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_double_separator_trailing_slash_missing_single_file_target_is_normalized_and_equivalent( +) { + let root = unique_temp_dir("compile_parent_double_trailing_missing_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let parent_double_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("..//missing.dag/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-double-trailing missing single-file compile"); + assert!( + !parent_double_trailing.status.success(), + "parent-double-trailing missing single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing single-file compile"); + assert!( + !absolute.status.success(), + "absolute missing single-file compile should fail" + ); + + assert_eq!( + parent_double_trailing.stdout, absolute.stdout, + "parent-double-trailing and absolute missing single-file compile stdout should match" + ); + assert_eq!( + parent_double_trailing.stderr, absolute.stderr, + "parent-double-trailing and absolute missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_double_trailing.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "parent-double-trailing missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_segment_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_segment_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_root = root.join("dsl"); + + let parent_segment = Command::new(daglang_bin()) + .arg("compile") + .arg("../dsl") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-segment root compile"); + assert!( + parent_segment.status.success(), + "parent-segment root compile should succeed: {}", + String::from_utf8_lossy(&parent_segment.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_root) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute root compile"); + assert!( + absolute.status.success(), + "absolute root compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_segment.stdout, absolute.stdout, + "parent-segment and absolute root compile stdout should match" + ); + assert_eq!( + parent_segment.stderr, absolute.stderr, + "parent-segment and absolute root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_segment.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_segment_trailing_slash_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_segment_trailing_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_root = root.join("dsl"); + + let parent_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("../dsl/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-trailing root compile"); + assert!( + parent_trailing.status.success(), + "parent-trailing root compile should succeed: {}", + String::from_utf8_lossy(&parent_trailing.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_root) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute root compile"); + assert!( + absolute.status.success(), + "absolute root compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_trailing.stdout, absolute.stdout, + "parent-trailing and absolute root compile stdout should match" + ); + assert_eq!( + parent_trailing.stderr, absolute.stderr, + "parent-trailing and absolute root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_trailing.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_double_separator_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_double_separator_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_root = root.join("dsl"); + + let parent_double = Command::new(daglang_bin()) + .arg("compile") + .arg("..//dsl") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-double root compile"); + assert!( + parent_double.status.success(), + "parent-double root compile should succeed: {}", + String::from_utf8_lossy(&parent_double.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_root) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute root compile"); + assert!( + absolute.status.success(), + "absolute root compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_double.stdout, absolute.stdout, + "parent-double and absolute root compile stdout should match" + ); + assert_eq!( + parent_double.stderr, absolute.stderr, + "parent-double and absolute root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_double.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_double_separator_trailing_slash_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_double_trailing_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_root = root.join("dsl"); + + let parent_double_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("..//dsl/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-double-trailing root compile"); + assert!( + parent_double_trailing.status.success(), + "parent-double-trailing root compile should succeed: {}", + String::from_utf8_lossy(&parent_double_trailing.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_root) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute root compile"); + assert!( + absolute.status.success(), + "absolute root compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_double_trailing.stdout, absolute.stdout, + "parent-double-trailing and absolute root compile stdout should match" + ); + assert_eq!( + parent_double_trailing.stderr, absolute.stderr, + "parent-double-trailing and absolute root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_double_trailing.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_segment_single_file_target_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_segment_single_file_target"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root child"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root sample"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let parent_segment = Command::new(daglang_bin()) + .arg("compile") + .arg("../sample/main.dag") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-segment single-file compile"); + assert!( + parent_segment.status.success(), + "parent-segment single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_segment.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&source) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute single-file compile"); + assert!( + absolute.status.success(), + "absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_segment.stdout, absolute.stdout, + "parent-segment and absolute single-file compile stdout should match" + ); + assert_eq!( + parent_segment.stderr, absolute.stderr, + "parent-segment and absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_segment.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_segment_trailing_slash_single_file_target_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_segment_trailing_single_file_target"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root child"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root sample"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let parent_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("../sample/main.dag/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-trailing single-file compile"); + assert!( + parent_trailing.status.success(), + "parent-trailing single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_trailing.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&source) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute single-file compile"); + assert!( + absolute.status.success(), + "absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_trailing.stdout, absolute.stdout, + "parent-trailing and absolute single-file compile stdout should match" + ); + assert_eq!( + parent_trailing.stderr, absolute.stderr, + "parent-trailing and absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_trailing.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_double_separator_single_file_target_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_double_single_file_target"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root child"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root sample"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let parent_double = Command::new(daglang_bin()) + .arg("compile") + .arg("..//sample/main.dag") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-double single-file compile"); + assert!( + parent_double.status.success(), + "parent-double single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_double.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&source) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute single-file compile"); + assert!( + absolute.status.success(), + "absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_double.stdout, absolute.stdout, + "parent-double and absolute single-file compile stdout should match" + ); + assert_eq!( + parent_double.stderr, absolute.stderr, + "parent-double and absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_double.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_double_separator_trailing_slash_single_file_target_matches_absolute_output() +{ + let root = unique_temp_dir("compile_parent_double_trailing_single_file_target"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root child"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root sample"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let parent_double_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("..//sample/main.dag/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-double-trailing single-file compile"); + assert!( + parent_double_trailing.status.success(), + "parent-double-trailing single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_double_trailing.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&source) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute single-file compile"); + assert!( + absolute.status.success(), + "absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_double_trailing.stdout, absolute.stdout, + "parent-double-trailing and absolute single-file compile stdout should match" + ); + assert_eq!( + parent_double_trailing.stderr, absolute.stderr, + "parent-double-trailing and absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_double_trailing.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_segment_missing_root_is_normalized_and_equivalent() { + let root = unique_temp_dir("compile_parent_curdir_segment_missing_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let parent_curdir_segment = Command::new(daglang_bin()) + .arg("compile") + .arg(".././missing_root") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-segment missing-root compile"); + assert!( + !parent_curdir_segment.status.success(), + "parent-curdir-segment missing-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing-root compile"); + assert!( + !absolute.status.success(), + "absolute missing-root compile should fail" + ); + + assert_eq!( + parent_curdir_segment.stdout, absolute.stdout, + "parent-curdir-segment and absolute missing-root compile stdout should match" + ); + assert_eq!( + parent_curdir_segment.stderr, absolute.stderr, + "parent-curdir-segment and absolute missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_curdir_segment.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "parent-curdir-segment missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_double_separator_missing_root_is_normalized_and_equivalent() { + let root = unique_temp_dir("compile_parent_curdir_double_missing_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let parent_curdir_double = Command::new(daglang_bin()) + .arg("compile") + .arg(".././missing_root//") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-double missing-root compile"); + assert!( + !parent_curdir_double.status.success(), + "parent-curdir-double missing-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing-root compile"); + assert!( + !absolute.status.success(), + "absolute missing-root compile should fail" + ); + + assert_eq!( + parent_curdir_double.stdout, absolute.stdout, + "parent-curdir-double and absolute missing-root compile stdout should match" + ); + assert_eq!( + parent_curdir_double.stderr, absolute.stderr, + "parent-curdir-double and absolute missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_curdir_double.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "parent-curdir-double missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_trailing_slash_missing_root_is_normalized_and_equivalent() { + let root = unique_temp_dir("compile_parent_curdir_trailing_missing_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let parent_curdir_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(".././missing_root/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-trailing missing-root compile"); + assert!( + !parent_curdir_trailing.status.success(), + "parent-curdir-trailing missing-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing-root compile"); + assert!( + !absolute.status.success(), + "absolute missing-root compile should fail" + ); + + assert_eq!( + parent_curdir_trailing.stdout, absolute.stdout, + "parent-curdir-trailing and absolute missing-root compile stdout should match" + ); + assert_eq!( + parent_curdir_trailing.stderr, absolute.stderr, + "parent-curdir-trailing and absolute missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_curdir_trailing.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "parent-curdir-trailing missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_segment_non_directory_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_curdir_segment_non_directory_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let parent_curdir_segment = Command::new(daglang_bin()) + .arg("compile") + .arg(".././input.txt") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-segment non-directory-root compile"); + assert!( + !parent_curdir_segment.status.success(), + "parent-curdir-segment non-directory-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&root_file) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute non-directory-root compile"); + assert!( + !absolute.status.success(), + "absolute non-directory-root compile should fail" + ); + + assert_eq!( + parent_curdir_segment.stdout, absolute.stdout, + "parent-curdir-segment and absolute non-directory-root compile stdout should match" + ); + assert_eq!( + parent_curdir_segment.stderr, absolute.stderr, + "parent-curdir-segment and absolute non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_curdir_segment.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "parent-curdir-segment non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_double_separator_non_directory_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_curdir_double_non_directory_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let parent_curdir_double = Command::new(daglang_bin()) + .arg("compile") + .arg(".././input.txt//") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-double non-directory-root compile"); + assert!( + !parent_curdir_double.status.success(), + "parent-curdir-double non-directory-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&root_file) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute non-directory-root compile"); + assert!( + !absolute.status.success(), + "absolute non-directory-root compile should fail" + ); + + assert_eq!( + parent_curdir_double.stdout, absolute.stdout, + "parent-curdir-double and absolute non-directory-root compile stdout should match" + ); + assert_eq!( + parent_curdir_double.stderr, absolute.stderr, + "parent-curdir-double and absolute non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_curdir_double.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "parent-curdir-double non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_trailing_slash_non_directory_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_curdir_trailing_non_directory_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let parent_curdir_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(".././input.txt/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-trailing non-directory-root compile"); + assert!( + !parent_curdir_trailing.status.success(), + "parent-curdir-trailing non-directory-root compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&root_file) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute non-directory-root compile"); + assert!( + !absolute.status.success(), + "absolute non-directory-root compile should fail" + ); + + assert_eq!( + parent_curdir_trailing.stdout, absolute.stdout, + "parent-curdir-trailing and absolute non-directory-root compile stdout should match" + ); + assert_eq!( + parent_curdir_trailing.stderr, absolute.stderr, + "parent-curdir-trailing and absolute non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_curdir_trailing.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "parent-curdir-trailing non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_segment_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_curdir_segment_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_root = root.join("dsl"); + + let parent_curdir_segment = Command::new(daglang_bin()) + .arg("compile") + .arg(".././dsl") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-segment root compile"); + assert!( + parent_curdir_segment.status.success(), + "parent-curdir-segment root compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_segment.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_root) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute root compile"); + assert!( + absolute.status.success(), + "absolute root compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_curdir_segment.stdout, absolute.stdout, + "parent-curdir-segment and absolute root compile stdout should match" + ); + assert_eq!( + parent_curdir_segment.stderr, absolute.stderr, + "parent-curdir-segment and absolute root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_curdir_segment.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_double_separator_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_curdir_double_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_root = root.join("dsl"); + + let parent_curdir_double = Command::new(daglang_bin()) + .arg("compile") + .arg(".././dsl//") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-double root compile"); + assert!( + parent_curdir_double.status.success(), + "parent-curdir-double root compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_double.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_root) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute root compile"); + assert!( + absolute.status.success(), + "absolute root compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_curdir_double.stdout, absolute.stdout, + "parent-curdir-double and absolute root compile stdout should match" + ); + assert_eq!( + parent_curdir_double.stderr, absolute.stderr, + "parent-curdir-double and absolute root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_curdir_double.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_trailing_slash_root_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_curdir_trailing_root"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_root = root.join("dsl"); + + let parent_curdir_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(".././dsl/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-trailing root compile"); + assert!( + parent_curdir_trailing.status.success(), + "parent-curdir-trailing root compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_trailing.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_root) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute root compile"); + assert!( + absolute.status.success(), + "absolute root compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_curdir_trailing.stdout, absolute.stdout, + "parent-curdir-trailing and absolute root compile stdout should match" + ); + assert_eq!( + parent_curdir_trailing.stderr, absolute.stderr, + "parent-curdir-trailing and absolute root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_curdir_trailing.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_segment_missing_single_file_target_is_normalized_and_equivalent() { + let root = unique_temp_dir("compile_parent_curdir_segment_missing_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let parent_curdir_segment = Command::new(daglang_bin()) + .arg("compile") + .arg(".././missing.dag") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-segment missing single-file compile"); + assert!( + !parent_curdir_segment.status.success(), + "parent-curdir-segment missing single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing single-file compile"); + assert!( + !absolute.status.success(), + "absolute missing single-file compile should fail" + ); + + assert_eq!( + parent_curdir_segment.stdout, absolute.stdout, + "parent-curdir-segment and absolute missing single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_segment.stderr, absolute.stderr, + "parent-curdir-segment and absolute missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_curdir_segment.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "parent-curdir-segment missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_double_separator_missing_single_file_target_is_normalized_and_equivalent( +) { + let root = unique_temp_dir("compile_parent_curdir_double_missing_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let parent_curdir_double = Command::new(daglang_bin()) + .arg("compile") + .arg(".././missing.dag//") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-double missing single-file compile"); + assert!( + !parent_curdir_double.status.success(), + "parent-curdir-double missing single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing single-file compile"); + assert!( + !absolute.status.success(), + "absolute missing single-file compile should fail" + ); + + assert_eq!( + parent_curdir_double.stdout, absolute.stdout, + "parent-curdir-double and absolute missing single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_double.stderr, absolute.stderr, + "parent-curdir-double and absolute missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_curdir_double.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "parent-curdir-double missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_trailing_slash_missing_single_file_target_is_normalized_and_equivalent( +) { + let root = unique_temp_dir("compile_parent_curdir_trailing_missing_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let parent_curdir_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(".././missing.dag/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-trailing missing single-file compile"); + assert!( + !parent_curdir_trailing.status.success(), + "parent-curdir-trailing missing single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&missing) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute missing single-file compile"); + assert!( + !absolute.status.success(), + "absolute missing single-file compile should fail" + ); + + assert_eq!( + parent_curdir_trailing.stdout, absolute.stdout, + "parent-curdir-trailing and absolute missing single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_trailing.stderr, absolute.stderr, + "parent-curdir-trailing and absolute missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_curdir_trailing.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "parent-curdir-trailing missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_segment_invalid_single_file_target_is_normalized_and_equivalent() { + let root = unique_temp_dir("compile_parent_segment_invalid_single_file_target"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let parent_segment = Command::new(daglang_bin()) + .arg("compile") + .arg("../invalid.dag") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-segment invalid single-file compile"); + assert!( + !parent_segment.status.success(), + "parent-segment invalid single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&invalid_target) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute invalid single-file compile"); + assert!( + !absolute.status.success(), + "absolute invalid single-file compile should fail" + ); + + assert_eq!( + parent_segment.stdout, absolute.stdout, + "parent-segment and absolute invalid single-file compile stdout should match" + ); + assert_eq!( + parent_segment.stderr, absolute.stderr, + "parent-segment and absolute invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_segment.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "parent-segment invalid single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_segment_trailing_slash_invalid_single_file_target_is_normalized_and_equivalent( +) { + let root = unique_temp_dir("compile_parent_segment_trailing_invalid_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let parent_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("../invalid.dag/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-trailing invalid single-file compile"); + assert!( + !parent_trailing.status.success(), + "parent-trailing invalid single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&invalid_target) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute invalid single-file compile"); + assert!( + !absolute.status.success(), + "absolute invalid single-file compile should fail" + ); + + assert_eq!( + parent_trailing.stdout, absolute.stdout, + "parent-trailing and absolute invalid single-file compile stdout should match" + ); + assert_eq!( + parent_trailing.stderr, absolute.stderr, + "parent-trailing and absolute invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_trailing.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "parent-trailing invalid single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_double_separator_invalid_single_file_target_is_normalized_and_equivalent() { + let root = unique_temp_dir("compile_parent_double_invalid_single_file_target"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let parent_double = Command::new(daglang_bin()) + .arg("compile") + .arg("..//invalid.dag") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-double invalid single-file compile"); + assert!( + !parent_double.status.success(), + "parent-double invalid single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&invalid_target) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute invalid single-file compile"); + assert!( + !absolute.status.success(), + "absolute invalid single-file compile should fail" + ); + + assert_eq!( + parent_double.stdout, absolute.stdout, + "parent-double and absolute invalid single-file compile stdout should match" + ); + assert_eq!( + parent_double.stderr, absolute.stderr, + "parent-double and absolute invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_double.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "parent-double invalid single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_double_separator_trailing_slash_invalid_single_file_target_is_normalized_and_equivalent( +) { + let root = unique_temp_dir("compile_parent_double_trailing_invalid_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let parent_double_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("..//invalid.dag/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-double-trailing invalid single-file compile"); + assert!( + !parent_double_trailing.status.success(), + "parent-double-trailing invalid single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&invalid_target) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute invalid single-file compile"); + assert!( + !absolute.status.success(), + "absolute invalid single-file compile should fail" + ); + + assert_eq!( + parent_double_trailing.stdout, absolute.stdout, + "parent-double-trailing and absolute invalid single-file compile stdout should match" + ); + assert_eq!( + parent_double_trailing.stderr, absolute.stderr, + "parent-double-trailing and absolute invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_double_trailing.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "parent-double-trailing invalid single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_segment_invalid_single_file_target_is_normalized_and_equivalent() { + let root = unique_temp_dir("compile_parent_curdir_segment_invalid_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let parent_curdir_segment = Command::new(daglang_bin()) + .arg("compile") + .arg(".././invalid.dag") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-segment invalid single-file compile"); + assert!( + !parent_curdir_segment.status.success(), + "parent-curdir-segment invalid single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&invalid_target) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute invalid single-file compile"); + assert!( + !absolute.status.success(), + "absolute invalid single-file compile should fail" + ); + + assert_eq!( + parent_curdir_segment.stdout, absolute.stdout, + "parent-curdir-segment and absolute invalid single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_segment.stderr, absolute.stderr, + "parent-curdir-segment and absolute invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_curdir_segment.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "parent-curdir-segment invalid single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_double_separator_invalid_single_file_target_is_normalized_and_equivalent( +) { + let root = unique_temp_dir("compile_parent_curdir_double_invalid_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let parent_curdir_double = Command::new(daglang_bin()) + .arg("compile") + .arg(".././invalid.dag//") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-double invalid single-file compile"); + assert!( + !parent_curdir_double.status.success(), + "parent-curdir-double invalid single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&invalid_target) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute invalid single-file compile"); + assert!( + !absolute.status.success(), + "absolute invalid single-file compile should fail" + ); + + assert_eq!( + parent_curdir_double.stdout, absolute.stdout, + "parent-curdir-double and absolute invalid single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_double.stderr, absolute.stderr, + "parent-curdir-double and absolute invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_curdir_double.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "parent-curdir-double invalid single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_trailing_slash_invalid_single_file_target_is_normalized_and_equivalent( +) { + let root = unique_temp_dir("compile_parent_curdir_trailing_invalid_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let parent_curdir_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(".././invalid.dag/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-trailing invalid single-file compile"); + assert!( + !parent_curdir_trailing.status.success(), + "parent-curdir-trailing invalid single-file compile should fail" + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&invalid_target) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute invalid single-file compile"); + assert!( + !absolute.status.success(), + "absolute invalid single-file compile should fail" + ); + + assert_eq!( + parent_curdir_trailing.stdout, absolute.stdout, + "parent-curdir-trailing and absolute invalid single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_trailing.stderr, absolute.stderr, + "parent-curdir-trailing and absolute invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&parent_curdir_trailing.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "parent-curdir-trailing invalid single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_segment_single_file_target_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_curdir_segment_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root child"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root sample"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let parent_curdir_segment = Command::new(daglang_bin()) + .arg("compile") + .arg(".././sample/main.dag") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-segment single-file compile"); + assert!( + parent_curdir_segment.status.success(), + "parent-curdir-segment single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_segment.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&source) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute single-file compile"); + assert!( + absolute.status.success(), + "absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_curdir_segment.stdout, absolute.stdout, + "parent-curdir-segment and absolute single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_segment.stderr, absolute.stderr, + "parent-curdir-segment and absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_curdir_segment.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_double_separator_single_file_target_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_curdir_double_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root child"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root sample"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let parent_curdir_double = Command::new(daglang_bin()) + .arg("compile") + .arg(".././sample//main.dag") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-double single-file compile"); + assert!( + parent_curdir_double.status.success(), + "parent-curdir-double single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_double.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&source) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute single-file compile"); + assert!( + absolute.status.success(), + "absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_curdir_double.stdout, absolute.stdout, + "parent-curdir-double and absolute single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_double.stderr, absolute.stderr, + "parent-curdir-double and absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_curdir_double.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_parent_curdir_trailing_slash_single_file_target_matches_absolute_output() { + let root = unique_temp_dir("compile_parent_curdir_trailing_single_file"); + std::fs::create_dir_all(root.join("child")).expect("failed to create temp root child"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root sample"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let parent_curdir_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(".././sample/main.dag/") + .current_dir(root.join("child")) + .output() + .expect("failed to run parent-curdir-trailing single-file compile"); + assert!( + parent_curdir_trailing.status.success(), + "parent-curdir-trailing single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_trailing.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&source) + .current_dir(root.join("child")) + .output() + .expect("failed to run absolute single-file compile"); + assert!( + absolute.status.success(), + "absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + parent_curdir_trailing.stdout, absolute.stdout, + "parent-curdir-trailing and absolute single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_trailing.stderr, absolute.stderr, + "parent-curdir-trailing and absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_curdir_trailing.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_trailing_slash_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_trailing_slash_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag/") + .current_dir(&root) + .output() + .expect("failed to run trailing-slash single-file compile"); + assert!( + trailing_slash.status.success(), + "trailing-slash single-file compile should succeed: {}", + String::from_utf8_lossy(&trailing_slash.stderr) + ); + + assert_eq!( + plain.stdout, trailing_slash.stdout, + "plain and trailing-slash single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, trailing_slash.stderr, + "plain and trailing-slash single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir = Command::new(daglang_bin()) + .arg("compile") + .arg("./sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment single-file compile"); + assert!( + curdir.status.success(), + "curdir-segment single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir.stderr) + ); + + assert_eq!( + plain.stdout, curdir.stdout, + "plain and curdir single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir.stderr, + "plain and curdir single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_trailing_slash_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_trailing_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_segment_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("./sample/main.dag/") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-trailing single-file compile"); + assert!( + curdir_segment_trailing.status.success(), + "curdir-segment-trailing single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_trailing.stderr) + ); + + assert_eq!( + plain.stdout, curdir_segment_trailing.stdout, + "plain and curdir-segment-trailing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_trailing.stderr, + "plain and curdir-segment-trailing single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_trailing_slash_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_trailing_single_file_target", + ); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_curdir_segment_trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg(".//sample/main.dag/") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-trailing-slash single-file compile"); + assert!( + dot_double_separator_curdir_segment_trailing_slash.status.success(), + "dot-double-separator-curdir-segment-trailing-slash single-file compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_curdir_segment_trailing_slash.stderr) + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_trailing_slash.stdout, + "plain and dot-double-separator-curdir-segment-trailing-slash single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_trailing_slash.stderr, + "plain and dot-double-separator-curdir-segment-trailing-slash single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_suffix_single_file_target_matches_plain_relative_output( +) { + let root = + unique_temp_dir("compile_dot_double_separator_curdir_segment_suffix_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//sample/main.dag/./.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-suffix single-file compile"); + assert!( + dot_double_separator_curdir_segment_suffix.status.success(), + "dot-double-separator-curdir-segment-suffix single-file compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_curdir_segment_suffix.stderr) + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_segment_suffix.stdout, + "plain and dot-double-separator-curdir-segment-suffix single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_segment_suffix.stderr, + "plain and dot-double-separator-curdir-segment-suffix single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_suffix_single_file_target_matches_plain_relative_output( +) { + let root = + unique_temp_dir("compile_dot_double_separator_curdir_suffix_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//sample/main.dag/.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-suffix single-file compile"); + assert!( + dot_double_separator_curdir_suffix.status.success(), + "dot-double-separator-curdir-suffix single-file compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_suffix.stdout, + "plain and dot-double-separator-curdir-suffix single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_suffix.stderr, + "plain and dot-double-separator-curdir-suffix single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_suffix_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_suffix_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./sample/main.dag/.") + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix single-file compile"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_suffix_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_suffix_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./sample/main.dag/./.") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-suffix single-file compile"); + assert!( + curdir_segment_suffix.status.success(), + "curdir-segment-suffix single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_segment_suffix.stdout, + "plain and curdir-segment-suffix single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_suffix.stderr, + "plain and curdir-segment-suffix single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_suffix_double_separator_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_curdir_suffix_double_separator_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg("./sample/main.dag//.") + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix-double-separator single-file compile"); + assert!( + curdir_suffix_double_separator.status.success(), + "curdir-suffix-double-separator single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_suffix_double_separator.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix_double_separator.stdout, + "plain and curdir-suffix-double-separator single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix_double_separator.stderr, + "plain and curdir-suffix-double-separator single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_double_separator_suffix_single_file_target_matches_plain_relative_output( +) { + let root = + unique_temp_dir("compile_curdir_segment_double_separator_suffix_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./sample/main.dag//./.") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-double-separator-suffix single-file compile"); + assert!( + curdir_segment_double_separator_suffix.status.success(), + "curdir-segment-double-separator-suffix single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_double_separator_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_segment_double_separator_suffix.stdout, + "plain and curdir-segment-double-separator-suffix single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_double_separator_suffix.stderr, + "plain and curdir-segment-double-separator-suffix single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_double_separator_suffix_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_double_separator_suffix_single_file_target", + ); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//sample/main.dag//./.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-double-separator-suffix single-file compile"); + assert!( + dot_double_separator_curdir_segment_double_separator_suffix + .status + .success(), + "dot-double-separator-curdir-segment-double-separator-suffix single-file compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_curdir_segment_double_separator_suffix.stderr) + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_double_separator_suffix.stdout, + "plain and dot-double-separator-curdir-segment-double-separator-suffix single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_double_separator_suffix.stderr, + "plain and dot-double-separator-curdir-segment-double-separator-suffix single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_double_separator_single_file_target_matches_plain_relative_output() +{ + let root = unique_temp_dir("compile_curdir_segment_double_separator_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg("./sample/main.dag//") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-double-separator single-file compile"); + assert!( + curdir_segment_double_separator.status.success(), + "curdir-segment-double-separator single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_double_separator.stderr) + ); + + assert_eq!( + plain.stdout, curdir_segment_double_separator.stdout, + "plain and curdir-segment-double-separator single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_double_separator.stderr, + "plain and curdir-segment-double-separator single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_double_separator_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_double_separator_single_file_target", + ); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//sample/main.dag//") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-double-separator single-file compile"); + assert!( + dot_double_separator_curdir_segment_double_separator.status.success(), + "dot-double-separator-curdir-segment-double-separator single-file compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_curdir_segment_double_separator.stderr) + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_double_separator.stdout, + "plain and dot-double-separator-curdir-segment-double-separator single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_double_separator.stderr, + "plain and dot-double-separator-curdir-segment-double-separator single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_double_separator_non_directory_root_matches_plain_relative_output() +{ + let root = unique_temp_dir("compile_curdir_segment_double_separator_non_directory_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run plain non-directory-root compile"); + assert!( + !plain.status.success(), + "plain non-directory-root compile should fail" + ); + + let curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg("./input.txt//") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-double-separator non-directory-root compile"); + assert!( + !curdir_segment_double_separator.status.success(), + "curdir-segment-double-separator non-directory-root compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_double_separator.stdout, + "plain and curdir-segment-double-separator non-directory-root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_double_separator.stderr, + "plain and curdir-segment-double-separator non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "curdir-segment-double-separator non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_double_separator_non_directory_root_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_double_separator_non_directory_root", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run plain non-directory-root compile"); + assert!( + !plain.status.success(), + "plain non-directory-root compile should fail" + ); + + let dot_double_separator_curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//input.txt//") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-double-separator non-directory-root compile"); + assert!( + !dot_double_separator_curdir_segment_double_separator.status.success(), + "dot-double-separator-curdir-segment-double-separator non-directory-root compile should fail" + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_double_separator.stdout, + "plain and dot-double-separator-curdir-segment-double-separator non-directory-root compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_double_separator.stderr, + "plain and dot-double-separator-curdir-segment-double-separator non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "dot-double-separator-curdir-segment-double-separator non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_double_separator_suffix_non_directory_root_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_double_separator_suffix_non_directory_root", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run plain non-directory-root compile"); + assert!( + !plain.status.success(), + "plain non-directory-root compile should fail" + ); + + let dot_double_separator_curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//input.txt//./.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-double-separator-suffix non-directory-root compile"); + assert!( + !dot_double_separator_curdir_segment_double_separator_suffix + .status + .success(), + "dot-double-separator-curdir-segment-double-separator-suffix non-directory-root compile should fail" + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_double_separator_suffix.stdout, + "plain and dot-double-separator-curdir-segment-double-separator-suffix non-directory-root compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_double_separator_suffix.stderr, + "plain and dot-double-separator-curdir-segment-double-separator-suffix non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "dot-double-separator-curdir-segment-double-separator-suffix non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_trailing_slash_non_directory_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_trailing_non_directory_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run plain non-directory-root compile"); + assert!( + !plain.status.success(), + "plain non-directory-root compile should fail" + ); + + let curdir_segment_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("./input.txt/") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-trailing non-directory-root compile"); + assert!( + !curdir_segment_trailing.status.success(), + "curdir-segment-trailing non-directory-root compile should fail" + ); + + assert_eq!( + plain.stdout, curdir_segment_trailing.stdout, + "plain and curdir-segment-trailing non-directory-root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_trailing.stderr, + "plain and curdir-segment-trailing non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "curdir-segment-trailing non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_trailing_slash_non_directory_root_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_trailing_non_directory_root", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run plain non-directory-root compile"); + assert!( + !plain.status.success(), + "plain non-directory-root compile should fail" + ); + + let dot_double_separator_curdir_segment_trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg(".//input.txt/") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-trailing-slash non-directory-root compile"); + assert!( + !dot_double_separator_curdir_segment_trailing_slash.status.success(), + "dot-double-separator-curdir-segment-trailing-slash non-directory-root compile should fail" + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_trailing_slash.stdout, + "plain and dot-double-separator-curdir-segment-trailing-slash non-directory-root compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_trailing_slash.stderr, + "plain and dot-double-separator-curdir-segment-trailing-slash non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "dot-double-separator-curdir-segment-trailing-slash non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_suffix_non_directory_root_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_segment_suffix_non_directory_root", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run plain non-directory-root compile"); + assert!( + !plain.status.success(), + "plain non-directory-root compile should fail" + ); + + let dot_double_separator_curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//input.txt/./.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-suffix non-directory-root compile"); + assert!( + !dot_double_separator_curdir_segment_suffix.status.success(), + "dot-double-separator-curdir-segment-suffix non-directory-root compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_segment_suffix.stdout, + "plain and dot-double-separator-curdir-segment-suffix non-directory-root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_segment_suffix.stderr, + "plain and dot-double-separator-curdir-segment-suffix non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "dot-double-separator-curdir-segment-suffix non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_suffix_non_directory_root_matches_plain_relative_output( +) { + let root = + unique_temp_dir("compile_dot_double_separator_curdir_suffix_non_directory_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run plain non-directory-root compile"); + assert!( + !plain.status.success(), + "plain non-directory-root compile should fail" + ); + + let dot_double_separator_curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//input.txt/.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-suffix non-directory-root compile"); + assert!( + !dot_double_separator_curdir_suffix.status.success(), + "dot-double-separator-curdir-suffix non-directory-root compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_suffix.stdout, + "plain and dot-double-separator-curdir-suffix non-directory-root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_suffix.stderr, + "plain and dot-double-separator-curdir-suffix non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "dot-double-separator-curdir-suffix non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_trailing_slash_non_directory_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_trailing_slash_non_directory_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run plain non-directory-root compile"); + assert!( + !plain.status.success(), + "plain non-directory-root compile should fail" + ); + + let trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt/") + .current_dir(&root) + .output() + .expect("failed to run trailing-slash non-directory-root compile"); + assert!( + !trailing_slash.status.success(), + "trailing-slash non-directory-root compile should fail" + ); + + assert_eq!( + plain.stdout, trailing_slash.stdout, + "plain and trailing-slash non-directory-root compile stdout should match" + ); + assert_eq!( + plain.stderr, trailing_slash.stderr, + "plain and trailing-slash non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "trailing-slash non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_double_separator_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_double_separator_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let double_sep = Command::new(daglang_bin()) + .arg("compile") + .arg("sample//main.dag") + .current_dir(&root) + .output() + .expect("failed to run double-separator single-file compile"); + assert!( + double_sep.status.success(), + "double-separator single-file compile should succeed: {}", + String::from_utf8_lossy(&double_sep.stderr) + ); + + assert_eq!( + plain.stdout, double_sep.stdout, + "plain and double-separator single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, double_sep.stderr, + "plain and double-separator single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_relative_and_absolute_single_file_targets_are_equivalent() { + let root = unique_temp_dir("compile_relative_absolute_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let relative = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run relative single-file compile"); + assert!( + relative.status.success(), + "relative single-file compile should succeed: {}", + String::from_utf8_lossy(&relative.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&source) + .current_dir(&root) + .output() + .expect("failed to run absolute single-file compile"); + assert!( + absolute.status.success(), + "absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute single-file compile stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&relative.stderr)); + assert!( + String::from_utf8_lossy(&relative.stdout).contains("Compiled 1 module(s)"), + "single-file compile summary missing: {}", + String::from_utf8_lossy(&relative.stdout) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_default_root_missing_in_cwd_exits_nonzero() { + let root = unique_temp_dir("compile_default_root_missing"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let expected = root.join("dsl"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .current_dir(&root) + .output() + .expect("failed to run compile default-root check"); + + assert!( + !output.status.success(), + "compile default-root run should fail when dsl directory is missing" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&format!( + "resolve error: invalid discovery root {}: does not exist", + expected.display() + )), + "default-root missing diagnostic should include normalized absolute dsl path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_default_root_non_directory_in_cwd_exits_nonzero() { + let root = unique_temp_dir("compile_default_root_non_directory"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let dsl_file = root.join("dsl"); + std::fs::write(&dsl_file, "not a directory").expect("failed to write dsl file"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .current_dir(&root) + .output() + .expect("failed to run compile default-root check"); + + assert!( + !output.status.success(), + "compile default-root run should fail when dsl path is not a directory" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&format!( + "resolve error: invalid discovery root {}: is not a directory", + dsl_file.display() + )), + "default-root non-directory diagnostic should include normalized absolute dsl path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_default_root_matches_explicit_dsl_output() { + let root = unique_temp_dir("compile_default_root_matches_explicit"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let default = Command::new(daglang_bin()) + .arg("compile") + .current_dir(&root) + .output() + .expect("failed to run default-root compile"); + assert!( + default.status.success(), + "default-root compile should succeed: {}", + String::from_utf8_lossy(&default.stderr) + ); + + let explicit = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run explicit-root compile"); + assert!( + explicit.status.success(), + "explicit-root compile should succeed: {}", + String::from_utf8_lossy(&explicit.stderr) + ); + + assert_eq!( + default.stdout, explicit.stdout, + "default-root and explicit dsl-root compile stdout should match" + ); + assert_eq!( + default.stderr, explicit.stderr, + "default-root and explicit dsl-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&default.stderr)); + assert!( + String::from_utf8_lossy(&default.stdout).contains("Compiled 1 module(s)"), + "default-root compile summary should report a single compiled module: {}", + String::from_utf8_lossy(&default.stdout) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_relative_and_absolute_roots_are_equivalent() { + let root = unique_temp_dir("compile_relative_absolute_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_root = root.join("dsl"); + + let relative = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run relative-root compile"); + assert!( + relative.status.success(), + "relative-root compile should succeed: {}", + String::from_utf8_lossy(&relative.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_root) + .current_dir(&root) + .output() + .expect("failed to run absolute-root compile"); + assert!( + absolute.status.success(), + "absolute-root compile should succeed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute root compile stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&relative.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_mixed_segment_root_matches_canonical_absolute_output() { + let root = unique_temp_dir("compile_absolute_mixed_segment_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_mixed_root = root.join(".").join("dsl"); + let canonical_root = root.join("dsl"); + + let mixed = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_mixed_root) + .current_dir(&root) + .output() + .expect("failed to run mixed-segment absolute root compile"); + assert!( + mixed.status.success(), + "mixed-segment absolute root compile should succeed: {}", + String::from_utf8_lossy(&mixed.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + mixed.stdout, canonical.stdout, + "mixed-segment and canonical absolute-root compile stdout should match" + ); + assert_eq!( + mixed.stderr, canonical.stderr, + "mixed-segment and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&mixed.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_curdir_segment_trailing_slash_root_matches_canonical_absolute_output() { + let root = unique_temp_dir("compile_absolute_curdir_segment_trailing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_curdir_segment_trailing_root = PathBuf::from(format!("{}/./dsl/", root.display())); + let canonical_root = root.join("dsl"); + + let curdir_segment_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_curdir_segment_trailing_root) + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-trailing absolute root compile"); + assert!( + curdir_segment_trailing.status.success(), + "curdir-segment-trailing absolute root compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_segment_trailing.stdout, canonical.stdout, + "curdir-segment-trailing and canonical absolute-root compile stdout should match" + ); + assert_eq!( + curdir_segment_trailing.stderr, canonical.stderr, + "curdir-segment-trailing and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&curdir_segment_trailing.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_curdir_segment_double_separator_root_matches_canonical_absolute_output() { + let root = unique_temp_dir("compile_absolute_curdir_segment_double_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let canonical_root = root.join("dsl"); + let absolute_curdir_segment_double_separator_root = + PathBuf::from(format!("{}//./", canonical_root.display())); + + let curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_curdir_segment_double_separator_root) + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-double-separator absolute root compile"); + assert!( + curdir_segment_double_separator.status.success(), + "curdir-segment-double-separator absolute root compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_double_separator.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_segment_double_separator.stdout, canonical.stdout, + "curdir-segment-double-separator and canonical absolute-root compile stdout should match" + ); + assert_eq!( + curdir_segment_double_separator.stderr, canonical.stderr, + "curdir-segment-double-separator and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &curdir_segment_double_separator.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_curdir_suffix_root_matches_canonical_absolute_output() { + let root = unique_temp_dir("compile_absolute_curdir_suffix_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let canonical_root = root.join("dsl"); + let absolute_curdir_suffix_root = root.join("dsl/."); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_curdir_suffix_root) + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix absolute root compile"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix absolute root compile should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_suffix.stdout, canonical.stdout, + "curdir-suffix and canonical absolute-root compile stdout should match" + ); + assert_eq!( + curdir_suffix.stderr, canonical.stderr, + "curdir-suffix and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&curdir_suffix.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_curdir_suffix_double_separator_root_matches_canonical_absolute_output() { + let root = unique_temp_dir("compile_absolute_curdir_suffix_double_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let canonical_root = root.join("dsl"); + let absolute_curdir_suffix_double_separator_root = + PathBuf::from(format!("{}//.", canonical_root.display())); + + let curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_curdir_suffix_double_separator_root) + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix-double-separator absolute root compile"); + assert!( + curdir_suffix_double_separator.status.success(), + "curdir-suffix-double-separator absolute root compile should succeed: {}", + String::from_utf8_lossy(&curdir_suffix_double_separator.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_suffix_double_separator.stdout, canonical.stdout, + "curdir-suffix-double-separator and canonical absolute-root compile stdout should match" + ); + assert_eq!( + curdir_suffix_double_separator.stderr, canonical.stderr, + "curdir-suffix-double-separator and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &curdir_suffix_double_separator.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_curdir_segment_double_separator_suffix_root_matches_canonical_absolute_output( +) { + let root = unique_temp_dir("compile_absolute_curdir_segment_double_suffix_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let canonical_root = root.join("dsl"); + let absolute_curdir_segment_double_separator_suffix_root = + PathBuf::from(format!("{}//./.", canonical_root.display())); + + let curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_curdir_segment_double_separator_suffix_root) + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-double-separator-suffix absolute root compile"); + assert!( + curdir_segment_double_separator_suffix.status.success(), + "curdir-segment-double-separator-suffix absolute root compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_double_separator_suffix.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_segment_double_separator_suffix.stdout, canonical.stdout, + "curdir-segment-double-separator-suffix and canonical absolute-root compile stdout should match" + ); + assert_eq!( + curdir_segment_double_separator_suffix.stderr, canonical.stderr, + "curdir-segment-double-separator-suffix and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &curdir_segment_double_separator_suffix.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_curdir_segment_suffix_root_matches_canonical_absolute_output() { + let root = unique_temp_dir("compile_absolute_curdir_segment_suffix_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let canonical_root = root.join("dsl"); + let absolute_curdir_segment_suffix_root = root.join("dsl/./."); + + let curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_curdir_segment_suffix_root) + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-suffix absolute root compile"); + assert!( + curdir_segment_suffix.status.success(), + "curdir-segment-suffix absolute root compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_suffix.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_segment_suffix.stdout, canonical.stdout, + "curdir-segment-suffix and canonical absolute-root compile stdout should match" + ); + assert_eq!( + curdir_segment_suffix.stderr, canonical.stderr, + "curdir-segment-suffix and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&curdir_segment_suffix.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_curdir_segment_root_matches_canonical_absolute_output() { + let root = unique_temp_dir("compile_absolute_parent_curdir_segment_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_curdir_segment_root = root.join("anchor/.././dsl"); + let canonical_root = root.join("dsl"); + + let parent_curdir_segment = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_curdir_segment_root) + .current_dir(&root) + .output() + .expect("failed to run parent-curdir-segment absolute root compile"); + assert!( + parent_curdir_segment.status.success(), + "parent-curdir-segment absolute root compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_segment.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_curdir_segment.stdout, canonical.stdout, + "parent-curdir-segment and canonical absolute-root compile stdout should match" + ); + assert_eq!( + parent_curdir_segment.stderr, canonical.stderr, + "parent-curdir-segment and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_curdir_segment.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_curdir_segment_trailing_slash_root_matches_canonical_absolute_output( +) { + let root = unique_temp_dir("compile_absolute_parent_curdir_segment_trailing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_curdir_segment_trailing_root = + PathBuf::from(format!("{}/anchor/.././dsl/", root.display())); + let canonical_root = root.join("dsl"); + + let parent_curdir_segment_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_curdir_segment_trailing_root) + .current_dir(&root) + .output() + .expect("failed to run parent-curdir-segment-trailing absolute root compile"); + assert!( + parent_curdir_segment_trailing.status.success(), + "parent-curdir-segment-trailing absolute root compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_segment_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_curdir_segment_trailing.stdout, canonical.stdout, + "parent-curdir-segment-trailing and canonical absolute-root compile stdout should match" + ); + assert_eq!( + parent_curdir_segment_trailing.stderr, canonical.stderr, + "parent-curdir-segment-trailing and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &parent_curdir_segment_trailing.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_curdir_segment_double_separator_root_matches_canonical_absolute_output( +) { + let root = unique_temp_dir("compile_absolute_parent_curdir_segment_double_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_curdir_segment_double_root = + PathBuf::from(format!("{}/anchor/.././dsl//", root.display())); + let canonical_root = root.join("dsl"); + + let parent_curdir_segment_double = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_curdir_segment_double_root) + .current_dir(&root) + .output() + .expect("failed to run parent-curdir-segment-double absolute root compile"); + assert!( + parent_curdir_segment_double.status.success(), + "parent-curdir-segment-double absolute root compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_segment_double.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_curdir_segment_double.stdout, canonical.stdout, + "parent-curdir-segment-double and canonical absolute-root compile stdout should match" + ); + assert_eq!( + parent_curdir_segment_double.stderr, canonical.stderr, + "parent-curdir-segment-double and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &parent_curdir_segment_double.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_curdir_segment_double_separator_trailing_slash_root_matches_canonical_absolute_output( +) { + let root = + unique_temp_dir("compile_absolute_parent_curdir_segment_double_trailing_slash_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_curdir_segment_double_separator_trailing_root = + PathBuf::from(format!("{}/anchor//.././dsl/", root.display())); + let canonical_root = root.join("dsl"); + + let parent_curdir_segment_double_separator_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_curdir_segment_double_separator_trailing_root) + .current_dir(&root) + .output() + .expect("failed to run parent-curdir-segment-double-separator-trailing absolute root compile"); + assert!( + parent_curdir_segment_double_separator_trailing.status.success(), + "parent-curdir-segment-double-separator-trailing absolute root compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_segment_double_separator_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_curdir_segment_double_separator_trailing.stdout, canonical.stdout, + "parent-curdir-segment-double-separator-trailing and canonical absolute-root compile stdout should match" + ); + assert_eq!( + parent_curdir_segment_double_separator_trailing.stderr, canonical.stderr, + "parent-curdir-segment-double-separator-trailing and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &parent_curdir_segment_double_separator_trailing.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_segment_root_matches_canonical_absolute_output() { + let root = unique_temp_dir("compile_absolute_parent_segment_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_segment_root = root.join("dsl/sample/.."); + let canonical_root = root.join("dsl"); + + let parent_segment = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_segment_root) + .current_dir(&root) + .output() + .expect("failed to run parent-segment absolute root compile"); + assert!( + parent_segment.status.success(), + "parent-segment absolute root compile should succeed: {}", + String::from_utf8_lossy(&parent_segment.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_segment.stdout, canonical.stdout, + "parent-segment and canonical absolute-root compile stdout should match" + ); + assert_eq!( + parent_segment.stderr, canonical.stderr, + "parent-segment and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_segment.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_segment_double_separator_root_matches_canonical_absolute_output() { + let root = unique_temp_dir("compile_absolute_parent_segment_double_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_segment_double_root = + PathBuf::from(format!("{}/dsl/sample/..//", root.display())); + let canonical_root = root.join("dsl"); + + let parent_segment_double = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_segment_double_root) + .current_dir(&root) + .output() + .expect("failed to run parent-segment-double absolute root compile"); + assert!( + parent_segment_double.status.success(), + "parent-segment-double absolute root compile should succeed: {}", + String::from_utf8_lossy(&parent_segment_double.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_segment_double.stdout, canonical.stdout, + "parent-segment-double and canonical absolute-root compile stdout should match" + ); + assert_eq!( + parent_segment_double.stderr, canonical.stderr, + "parent-segment-double and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_segment_double.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_segment_double_separator_trailing_slash_root_matches_canonical_absolute_output( +) { + let root = unique_temp_dir("compile_absolute_parent_segment_double_trailing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_segment_double_trailing_root = + PathBuf::from(format!("{}/dsl/sample/..//", root.display())); + let canonical_root = root.join("dsl"); + + let parent_segment_double_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_segment_double_trailing_root) + .current_dir(&root) + .output() + .expect("failed to run parent-segment-double-trailing absolute root compile"); + assert!( + parent_segment_double_trailing.status.success(), + "parent-segment-double-trailing absolute root compile should succeed: {}", + String::from_utf8_lossy(&parent_segment_double_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_segment_double_trailing.stdout, canonical.stdout, + "parent-segment-double-trailing and canonical absolute-root compile stdout should match" + ); + assert_eq!( + parent_segment_double_trailing.stderr, canonical.stderr, + "parent-segment-double-trailing and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &parent_segment_double_trailing.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_segment_trailing_slash_root_matches_canonical_absolute_output() { + let root = unique_temp_dir("compile_absolute_parent_segment_trailing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_segment_trailing_root = + PathBuf::from(format!("{}/", root.join("dsl/sample/..").display())); + let canonical_root = root.join("dsl"); + + let parent_segment_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_segment_trailing_root) + .current_dir(&root) + .output() + .expect("failed to run parent-segment-trailing absolute root compile"); + assert!( + parent_segment_trailing.status.success(), + "parent-segment-trailing absolute root compile should succeed: {}", + String::from_utf8_lossy(&parent_segment_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_segment_trailing.stdout, canonical.stdout, + "parent-segment-trailing and canonical absolute-root compile stdout should match" + ); + assert_eq!( + parent_segment_trailing.stderr, canonical.stderr, + "parent-segment-trailing and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_segment_trailing.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_double_separator_root_matches_canonical_absolute_output() { + let root = unique_temp_dir("compile_absolute_double_separator_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_double_separator_root = PathBuf::from(format!("{}//dsl", root.display())); + let canonical_root = root.join("dsl"); + + let double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_double_separator_root) + .current_dir(&root) + .output() + .expect("failed to run double-separator absolute root compile"); + assert!( + double_separator.status.success(), + "double-separator absolute root compile should succeed: {}", + String::from_utf8_lossy(&double_separator.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + double_separator.stdout, canonical.stdout, + "double-separator and canonical absolute-root compile stdout should match" + ); + assert_eq!( + double_separator.stderr, canonical.stderr, + "double-separator and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&double_separator.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_trailing_slash_root_matches_canonical_absolute_output() { + let root = unique_temp_dir("compile_absolute_trailing_slash_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_trailing_slash_root = PathBuf::from(format!("{}/", root.join("dsl").display())); + let canonical_root = root.join("dsl"); + + let trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_trailing_slash_root) + .current_dir(&root) + .output() + .expect("failed to run trailing-slash absolute root compile"); + assert!( + trailing_slash.status.success(), + "trailing-slash absolute root compile should succeed: {}", + String::from_utf8_lossy(&trailing_slash.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + trailing_slash.stdout, canonical.stdout, + "trailing-slash and canonical absolute-root compile stdout should match" + ); + assert_eq!( + trailing_slash.stderr, canonical.stderr, + "trailing-slash and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&trailing_slash.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_double_separator_trailing_slash_root_matches_canonical_absolute_output() +{ + let root = unique_temp_dir("compile_absolute_double_separator_trailing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_double_separator_trailing_root = + PathBuf::from(format!("{}//dsl/", root.display())); + let canonical_root = root.join("dsl"); + + let double_separator_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_double_separator_trailing_root) + .current_dir(&root) + .output() + .expect("failed to run double-separator trailing absolute root compile"); + assert!( + double_separator_trailing.status.success(), + "double-separator trailing absolute root compile should succeed: {}", + String::from_utf8_lossy(&double_separator_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute root compile"); + assert!( + canonical.status.success(), + "canonical absolute root compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + double_separator_trailing.stdout, canonical.stdout, + "double-separator trailing and canonical absolute-root compile stdout should match" + ); + assert_eq!( + double_separator_trailing.stderr, canonical.stderr, + "double-separator trailing and canonical absolute-root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&double_separator_trailing.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_mixed_segment_single_file_target_matches_canonical_output() { + let root = unique_temp_dir("compile_absolute_mixed_segment_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_mixed_target = root.join(".").join("dsl/sample/main.dag"); + let canonical_target = root.join("dsl/sample/main.dag"); + + let mixed = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_mixed_target) + .current_dir(&root) + .output() + .expect("failed to run mixed-segment absolute single-file compile"); + assert!( + mixed.status.success(), + "mixed-segment absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&mixed.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + mixed.stdout, canonical.stdout, + "mixed-segment and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + mixed.stderr, canonical.stderr, + "mixed-segment and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&mixed.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_curdir_segment_trailing_slash_single_file_target_matches_canonical_output( +) { + let root = unique_temp_dir("compile_absolute_curdir_segment_trailing_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_curdir_segment_trailing_target = + PathBuf::from(format!("{}/./dsl/sample/main.dag/", root.display())); + let canonical_target = root.join("dsl/sample/main.dag"); + + let curdir_segment_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_curdir_segment_trailing_target) + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-trailing absolute single-file compile"); + assert!( + curdir_segment_trailing.status.success(), + "curdir-segment-trailing absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_segment_trailing.stdout, canonical.stdout, + "curdir-segment-trailing and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + curdir_segment_trailing.stderr, canonical.stderr, + "curdir-segment-trailing and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&curdir_segment_trailing.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_curdir_segment_double_separator_single_file_target_matches_canonical_output( +) { + let root = unique_temp_dir("compile_absolute_curdir_segment_double_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let canonical_target = root.join("dsl/sample/main.dag"); + let absolute_curdir_segment_double_separator_target = + PathBuf::from(format!("{}//./", canonical_target.display())); + + let curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_curdir_segment_double_separator_target) + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-double-separator absolute single-file compile"); + assert!( + curdir_segment_double_separator.status.success(), + "curdir-segment-double-separator absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_double_separator.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_segment_double_separator.stdout, canonical.stdout, + "curdir-segment-double-separator and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + curdir_segment_double_separator.stderr, canonical.stderr, + "curdir-segment-double-separator and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &curdir_segment_double_separator.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_curdir_suffix_single_file_target_matches_canonical_output() { + let root = unique_temp_dir("compile_absolute_curdir_suffix_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let canonical_target = root.join("dsl/sample/main.dag"); + let absolute_curdir_suffix_target = root.join("dsl/sample/main.dag/."); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_curdir_suffix_target) + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix absolute single-file compile"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_suffix.stdout, canonical.stdout, + "curdir-suffix and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + curdir_suffix.stderr, canonical.stderr, + "curdir-suffix and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&curdir_suffix.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_curdir_suffix_double_separator_single_file_target_matches_canonical_output( +) { + let root = unique_temp_dir("compile_absolute_curdir_suffix_double_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let canonical_target = root.join("dsl/sample/main.dag"); + let absolute_curdir_suffix_double_separator_target = + PathBuf::from(format!("{}//.", canonical_target.display())); + + let curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_curdir_suffix_double_separator_target) + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix-double-separator absolute single-file compile"); + assert!( + curdir_suffix_double_separator.status.success(), + "curdir-suffix-double-separator absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_suffix_double_separator.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_suffix_double_separator.stdout, canonical.stdout, + "curdir-suffix-double-separator and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + curdir_suffix_double_separator.stderr, canonical.stderr, + "curdir-suffix-double-separator and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &curdir_suffix_double_separator.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_curdir_segment_double_separator_suffix_single_file_target_matches_canonical_output( +) { + let root = unique_temp_dir("compile_absolute_curdir_segment_double_suffix_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let canonical_target = root.join("dsl/sample/main.dag"); + let absolute_curdir_segment_double_separator_suffix_target = + PathBuf::from(format!("{}//./.", canonical_target.display())); + + let curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_curdir_segment_double_separator_suffix_target) + .current_dir(&root) + .output() + .expect( + "failed to run curdir-segment-double-separator-suffix absolute single-file compile", + ); + assert!( + curdir_segment_double_separator_suffix.status.success(), + "curdir-segment-double-separator-suffix absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_double_separator_suffix.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_segment_double_separator_suffix.stdout, canonical.stdout, + "curdir-segment-double-separator-suffix and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + curdir_segment_double_separator_suffix.stderr, canonical.stderr, + "curdir-segment-double-separator-suffix and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &curdir_segment_double_separator_suffix.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_curdir_segment_suffix_single_file_target_matches_canonical_output() { + let root = unique_temp_dir("compile_absolute_curdir_segment_suffix_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let canonical_target = root.join("dsl/sample/main.dag"); + let absolute_curdir_segment_suffix_target = root.join("dsl/sample/main.dag/./."); + + let curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_curdir_segment_suffix_target) + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-suffix absolute single-file compile"); + assert!( + curdir_segment_suffix.status.success(), + "curdir-segment-suffix absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_suffix.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_segment_suffix.stdout, canonical.stdout, + "curdir-segment-suffix and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + curdir_segment_suffix.stderr, canonical.stderr, + "curdir-segment-suffix and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&curdir_segment_suffix.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_curdir_segment_single_file_target_matches_canonical_output() { + let root = unique_temp_dir("compile_absolute_parent_curdir_segment_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_curdir_segment_target = root.join("anchor/.././dsl/sample/main.dag"); + let canonical_target = root.join("dsl/sample/main.dag"); + + let parent_curdir_segment = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_curdir_segment_target) + .current_dir(&root) + .output() + .expect("failed to run parent-curdir-segment absolute single-file compile"); + assert!( + parent_curdir_segment.status.success(), + "parent-curdir-segment absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_segment.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_curdir_segment.stdout, canonical.stdout, + "parent-curdir-segment and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_segment.stderr, canonical.stderr, + "parent-curdir-segment and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_curdir_segment.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_curdir_segment_trailing_slash_single_file_target_matches_canonical_output( +) { + let root = unique_temp_dir("compile_absolute_parent_curdir_segment_trailing_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let canonical_target = root.join("dsl/sample/main.dag"); + let absolute_parent_curdir_segment_trailing_target = + PathBuf::from(format!("{}/anchor/.././dsl/sample/main.dag/", root.display())); + + let parent_curdir_segment_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_curdir_segment_trailing_target) + .current_dir(&root) + .output() + .expect("failed to run parent-curdir-segment-trailing absolute single-file compile"); + assert!( + parent_curdir_segment_trailing.status.success(), + "parent-curdir-segment-trailing absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_segment_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_curdir_segment_trailing.stdout, canonical.stdout, + "parent-curdir-segment-trailing and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_segment_trailing.stderr, canonical.stderr, + "parent-curdir-segment-trailing and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &parent_curdir_segment_trailing.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_curdir_segment_double_separator_single_file_target_matches_canonical_output( +) { + let root = unique_temp_dir("compile_absolute_parent_curdir_segment_double_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_curdir_segment_double_target = + PathBuf::from(format!("{}/anchor/.././dsl/sample//main.dag", root.display())); + let canonical_target = root.join("dsl/sample/main.dag"); + + let parent_curdir_segment_double = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_curdir_segment_double_target) + .current_dir(&root) + .output() + .expect("failed to run parent-curdir-segment-double absolute single-file compile"); + assert!( + parent_curdir_segment_double.status.success(), + "parent-curdir-segment-double absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_segment_double.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_curdir_segment_double.stdout, canonical.stdout, + "parent-curdir-segment-double and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_segment_double.stderr, canonical.stderr, + "parent-curdir-segment-double and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &parent_curdir_segment_double.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_curdir_segment_double_separator_trailing_slash_single_file_target_matches_canonical_output( +) { + let root = + unique_temp_dir("compile_absolute_parent_curdir_segment_double_trailing_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let canonical_target = root.join("dsl/sample/main.dag"); + let absolute_parent_curdir_segment_double_separator_trailing_target = + PathBuf::from(format!("{}/anchor//.././dsl/sample/main.dag/", root.display())); + + let parent_curdir_segment_double_separator_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_curdir_segment_double_separator_trailing_target) + .current_dir(&root) + .output() + .expect("failed to run parent-curdir-segment-double-separator-trailing absolute single-file compile"); + assert!( + parent_curdir_segment_double_separator_trailing.status.success(), + "parent-curdir-segment-double-separator-trailing absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_curdir_segment_double_separator_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_curdir_segment_double_separator_trailing.stdout, canonical.stdout, + "parent-curdir-segment-double-separator-trailing and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + parent_curdir_segment_double_separator_trailing.stderr, canonical.stderr, + "parent-curdir-segment-double-separator-trailing and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &parent_curdir_segment_double_separator_trailing.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_segment_single_file_target_matches_canonical_output() { + let root = unique_temp_dir("compile_absolute_parent_segment_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_segment_target = root.join("dsl/sample/../sample/main.dag"); + let canonical_target = root.join("dsl/sample/main.dag"); + + let parent_segment = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_segment_target) + .current_dir(&root) + .output() + .expect("failed to run parent-segment absolute single-file compile"); + assert!( + parent_segment.status.success(), + "parent-segment absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_segment.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_segment.stdout, canonical.stdout, + "parent-segment and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + parent_segment.stderr, canonical.stderr, + "parent-segment and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_segment.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_segment_double_separator_single_file_target_matches_canonical_output( +) { + let root = unique_temp_dir("compile_absolute_parent_segment_double_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_segment_double_target = + PathBuf::from(format!("{}/dsl/sample/..//sample/main.dag", root.display())); + let canonical_target = root.join("dsl/sample/main.dag"); + + let parent_segment_double = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_segment_double_target) + .current_dir(&root) + .output() + .expect("failed to run parent-segment-double absolute single-file compile"); + assert!( + parent_segment_double.status.success(), + "parent-segment-double absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_segment_double.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_segment_double.stdout, canonical.stdout, + "parent-segment-double and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + parent_segment_double.stderr, canonical.stderr, + "parent-segment-double and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_segment_double.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_segment_double_separator_trailing_slash_single_file_target_matches_canonical_output( +) { + let root = unique_temp_dir("compile_absolute_parent_segment_double_trailing_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_segment_double_trailing_target = + PathBuf::from(format!("{}/dsl/sample/..//sample/main.dag/", root.display())); + let canonical_target = root.join("dsl/sample/main.dag"); + + let parent_segment_double_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_segment_double_trailing_target) + .current_dir(&root) + .output() + .expect("failed to run parent-segment-double-trailing absolute single-file compile"); + assert!( + parent_segment_double_trailing.status.success(), + "parent-segment-double-trailing absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_segment_double_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_segment_double_trailing.stdout, canonical.stdout, + "parent-segment-double-trailing and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + parent_segment_double_trailing.stderr, canonical.stderr, + "parent-segment-double-trailing and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy( + &parent_segment_double_trailing.stderr + )); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_parent_segment_trailing_slash_single_file_target_matches_canonical_output() +{ + let root = unique_temp_dir("compile_absolute_parent_segment_trailing_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_parent_segment_trailing_target = + PathBuf::from(format!("{}/", root.join("dsl/sample/../sample/main.dag").display())); + let canonical_target = root.join("dsl/sample/main.dag"); + + let parent_segment_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_parent_segment_trailing_target) + .current_dir(&root) + .output() + .expect("failed to run parent-segment-trailing absolute single-file compile"); + assert!( + parent_segment_trailing.status.success(), + "parent-segment-trailing absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&parent_segment_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + parent_segment_trailing.stdout, canonical.stdout, + "parent-segment-trailing and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + parent_segment_trailing.stderr, canonical.stderr, + "parent-segment-trailing and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&parent_segment_trailing.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_double_separator_single_file_target_matches_canonical_output() { + let root = unique_temp_dir("compile_absolute_double_separator_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_double_separator_target = + PathBuf::from(format!("{}//dsl//sample//main.dag", root.display())); + let canonical_target = root.join("dsl/sample/main.dag"); + + let double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_double_separator_target) + .current_dir(&root) + .output() + .expect("failed to run double-separator absolute single-file compile"); + assert!( + double_separator.status.success(), + "double-separator absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&double_separator.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + double_separator.stdout, canonical.stdout, + "double-separator and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + double_separator.stderr, canonical.stderr, + "double-separator and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&double_separator.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_trailing_slash_single_file_target_matches_canonical_output() { + let root = unique_temp_dir("compile_absolute_trailing_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_trailing_target = + PathBuf::from(format!("{}/", root.join("dsl/sample/main.dag").display())); + let canonical_target = root.join("dsl/sample/main.dag"); + + let trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_trailing_target) + .current_dir(&root) + .output() + .expect("failed to run trailing-slash absolute single-file compile"); + assert!( + trailing_slash.status.success(), + "trailing-slash absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&trailing_slash.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + trailing_slash.stdout, canonical.stdout, + "trailing-slash and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + trailing_slash.stderr, canonical.stderr, + "trailing-slash and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&trailing_slash.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_double_separator_trailing_slash_single_file_target_matches_canonical_output( +) { + let root = unique_temp_dir("compile_absolute_double_separator_trailing_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + let absolute_double_separator_trailing_target = + PathBuf::from(format!("{}//dsl//sample//main.dag/", root.display())); + let canonical_target = root.join("dsl/sample/main.dag"); + + let double_separator_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(&absolute_double_separator_trailing_target) + .current_dir(&root) + .output() + .expect("failed to run double-separator trailing absolute single-file compile"); + assert!( + double_separator_trailing.status.success(), + "double-separator trailing absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&double_separator_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_target) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute single-file compile"); + assert!( + canonical.status.success(), + "canonical absolute single-file compile should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + double_separator_trailing.stdout, canonical.stdout, + "double-separator trailing and canonical absolute single-file compile stdout should match" + ); + assert_eq!( + double_separator_trailing.stderr, canonical.stderr, + "double-separator trailing and canonical absolute single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&double_separator_trailing.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_missing_root_variants_match_canonical_output() { + let root = unique_temp_dir("compile_absolute_missing_root_variants"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + std::fs::create_dir_all(root.join("anchor")).expect("failed to create anchor directory"); + let canonical_missing_root = root.join("missing_root"); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_missing_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute missing-root compile"); + assert!( + !canonical.status.success(), + "canonical absolute missing-root compile should fail" + ); + let canonical_stderr = String::from_utf8_lossy(&canonical.stderr); + assert_no_stage_failures(&canonical_stderr); + assert!( + canonical_stderr.contains(&canonical_missing_root.display().to_string()), + "canonical missing-root diagnostic should contain normalized absolute path: {canonical_stderr}" + ); + + let variants = vec![ + ("mixed", root.join(".").join("missing_root")), + ( + "curdir_segment_trailing", + PathBuf::from(format!("{}/./missing_root/", root.display())), + ), + ( + "curdir_segment_double_separator", + PathBuf::from(format!("{}//./", canonical_missing_root.display())), + ), + ("curdir_suffix", root.join("missing_root/.")), + ( + "curdir_suffix_double_separator", + PathBuf::from(format!("{}//.", canonical_missing_root.display())), + ), + ( + "curdir_segment_double_separator_suffix", + PathBuf::from(format!("{}//./.", canonical_missing_root.display())), + ), + ( + "curdir_segment_suffix", + PathBuf::from(format!("{}/./.", canonical_missing_root.display())), + ), + ("parent", root.join("anchor/../missing_root")), + ( + "parent_curdir_segment", + root.join("anchor/.././missing_root"), + ), + ( + "parent_curdir_segment_trailing", + PathBuf::from(format!("{}/anchor/.././missing_root/", root.display())), + ), + ( + "parent_curdir_segment_double_separator", + PathBuf::from(format!("{}/anchor/.././missing_root//", root.display())), + ), + ( + "parent_curdir_segment_double_separator_trailing", + PathBuf::from(format!("{}/anchor//.././missing_root/", root.display())), + ), + ( + "parent_double_separator", + PathBuf::from(format!("{}/anchor/..//missing_root", root.display())), + ), + ( + "parent_trailing", + PathBuf::from(format!("{}/", root.join("anchor/../missing_root").display())), + ), + ( + "parent_double_separator_trailing", + PathBuf::from(format!("{}/anchor/..//missing_root/", root.display())), + ), + ( + "double_separator", + PathBuf::from(format!("{}//missing_root", root.display())), + ), + ( + "trailing_slash", + PathBuf::from(format!("{}/", canonical_missing_root.display())), + ), + ( + "double_separator_trailing", + PathBuf::from(format!("{}//missing_root/", root.display())), + ), + ]; + + for (label, variant_path) in variants { + let variant = Command::new(daglang_bin()) + .arg("compile") + .arg(&variant_path) + .current_dir(&root) + .output() + .unwrap_or_else(|_| { + panic!("failed to run {label} absolute missing-root compile variant") + }); + assert!( + !variant.status.success(), + "{label} absolute missing-root compile variant should fail" + ); + assert_eq!( + variant.stdout, canonical.stdout, + "{label} missing-root variant stdout should match canonical output" + ); + assert_eq!( + variant.stderr, canonical.stderr, + "{label} missing-root variant stderr should match canonical output" + ); + } + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_non_directory_root_variants_match_canonical_output() { + let root = unique_temp_dir("compile_absolute_non_directory_root_variants"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + std::fs::create_dir_all(root.join("anchor")).expect("failed to create anchor directory"); + let canonical_non_directory_root = root.join("input.txt"); + std::fs::write(&canonical_non_directory_root, "not a directory") + .expect("failed to write non-directory root fixture"); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_non_directory_root) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute non-directory-root compile"); + assert!( + !canonical.status.success(), + "canonical absolute non-directory-root compile should fail" + ); + let canonical_stderr = String::from_utf8_lossy(&canonical.stderr); + assert_no_stage_failures(&canonical_stderr); + assert!( + canonical_stderr.contains(&canonical_non_directory_root.display().to_string()), + "canonical non-directory-root diagnostic should contain normalized absolute path: {canonical_stderr}" + ); + + let variants = vec![ + ("mixed", root.join(".").join("input.txt")), + ( + "curdir_segment_trailing", + PathBuf::from(format!("{}/./input.txt/", root.display())), + ), + ( + "curdir_segment_double_separator", + PathBuf::from(format!("{}//./", canonical_non_directory_root.display())), + ), + ("curdir_suffix", root.join("input.txt/.")), + ( + "curdir_suffix_double_separator", + PathBuf::from(format!("{}//.", canonical_non_directory_root.display())), + ), + ( + "curdir_segment_double_separator_suffix", + PathBuf::from(format!("{}//./.", canonical_non_directory_root.display())), + ), + ( + "curdir_segment_suffix", + PathBuf::from(format!("{}/./.", canonical_non_directory_root.display())), + ), + ("parent", root.join("anchor/../input.txt")), + ( + "parent_curdir_segment", + root.join("anchor/.././input.txt"), + ), + ( + "parent_curdir_segment_trailing", + PathBuf::from(format!("{}/anchor/.././input.txt/", root.display())), + ), + ( + "parent_curdir_segment_double_separator", + PathBuf::from(format!("{}/anchor/.././input.txt//", root.display())), + ), + ( + "parent_curdir_segment_double_separator_trailing", + PathBuf::from(format!("{}/anchor//.././input.txt/", root.display())), + ), + ( + "parent_double_separator", + PathBuf::from(format!("{}/anchor/..//input.txt", root.display())), + ), + ( + "parent_trailing", + PathBuf::from(format!("{}/", root.join("anchor/../input.txt").display())), + ), + ( + "parent_double_separator_trailing", + PathBuf::from(format!("{}/anchor/..//input.txt/", root.display())), + ), + ( + "double_separator", + PathBuf::from(format!("{}//input.txt", root.display())), + ), + ( + "trailing_slash", + PathBuf::from(format!("{}/", canonical_non_directory_root.display())), + ), + ( + "double_separator_trailing", + PathBuf::from(format!("{}//input.txt/", root.display())), + ), + ]; + + for (label, variant_path) in variants { + let variant = Command::new(daglang_bin()) + .arg("compile") + .arg(&variant_path) + .current_dir(&root) + .output() + .unwrap_or_else(|_| { + panic!("failed to run {label} absolute non-directory-root compile variant") + }); + assert!( + !variant.status.success(), + "{label} absolute non-directory-root compile variant should fail" + ); + assert_eq!( + variant.stdout, canonical.stdout, + "{label} non-directory-root variant stdout should match canonical output" + ); + assert_eq!( + variant.stderr, canonical.stderr, + "{label} non-directory-root variant stderr should match canonical output" + ); + } + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_missing_single_file_variants_match_canonical_output() { + let root = unique_temp_dir("compile_absolute_missing_single_file_variants"); + std::fs::create_dir_all(root.join("dsl/sample")).expect("failed to create temp root fixture"); + std::fs::create_dir_all(root.join("anchor")).expect("failed to create anchor directory"); + let canonical_missing_single_file = root.join("dsl/sample/missing.dag"); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_missing_single_file) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute missing single-file compile"); + assert!( + !canonical.status.success(), + "canonical absolute missing single-file compile should fail" + ); + let canonical_stderr = String::from_utf8_lossy(&canonical.stderr); + assert_no_stage_failures(&canonical_stderr); + assert!( + canonical_stderr.contains(&canonical_missing_single_file.display().to_string()), + "canonical missing single-file diagnostic should contain normalized absolute path: {canonical_stderr}" + ); + + let variants = vec![ + ("mixed", root.join(".").join("dsl/sample/missing.dag")), + ( + "curdir_segment_trailing", + PathBuf::from(format!("{}/./dsl/sample/missing.dag/", root.display())), + ), + ( + "curdir_segment_double_separator", + PathBuf::from(format!("{}//./", canonical_missing_single_file.display())), + ), + ("curdir_suffix", root.join("dsl/sample/missing.dag/.")), + ( + "curdir_suffix_double_separator", + PathBuf::from(format!("{}//.", canonical_missing_single_file.display())), + ), + ( + "curdir_segment_double_separator_suffix", + PathBuf::from(format!("{}//./.", canonical_missing_single_file.display())), + ), + ( + "curdir_segment_suffix", + PathBuf::from(format!("{}/./.", canonical_missing_single_file.display())), + ), + ("parent", root.join("dsl/sample/../sample/missing.dag")), + ( + "parent_curdir_segment", + root.join("dsl/sample/.././sample/missing.dag"), + ), + ( + "parent_curdir_segment_trailing", + PathBuf::from(format!( + "{}/dsl/sample/.././sample/missing.dag/", + root.display() + )), + ), + ( + "parent_curdir_segment_double_separator", + PathBuf::from(format!( + "{}/dsl/sample/.././sample//missing.dag", + root.display() + )), + ), + ( + "parent_curdir_segment_double_separator_trailing", + PathBuf::from(format!( + "{}/dsl/sample//.././sample/missing.dag/", + root.display() + )), + ), + ( + "parent_double_separator", + PathBuf::from(format!("{}/dsl/sample/..//sample/missing.dag", root.display())), + ), + ( + "parent_trailing", + PathBuf::from(format!("{}/", root.join("dsl/sample/../sample/missing.dag").display())), + ), + ( + "parent_double_separator_trailing", + PathBuf::from(format!("{}/dsl/sample/..//sample/missing.dag/", root.display())), + ), + ( + "double_separator", + PathBuf::from(format!("{}//dsl//sample//missing.dag", root.display())), + ), + ( + "trailing_slash", + PathBuf::from(format!("{}/", canonical_missing_single_file.display())), + ), + ( + "double_separator_trailing", + PathBuf::from(format!("{}//dsl//sample//missing.dag/", root.display())), + ), + ]; + + for (label, variant_path) in variants { + let variant = Command::new(daglang_bin()) + .arg("compile") + .arg(&variant_path) + .current_dir(&root) + .output() + .unwrap_or_else(|_| { + panic!("failed to run {label} absolute missing single-file compile variant") + }); + assert!( + !variant.status.success(), + "{label} absolute missing single-file compile variant should fail" + ); + assert_eq!( + variant.stdout, canonical.stdout, + "{label} missing single-file variant stdout should match canonical output" + ); + assert_eq!( + variant.stderr, canonical.stderr, + "{label} missing single-file variant stderr should match canonical output" + ); + } + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_absolute_invalid_single_file_variants_match_canonical_output() { + let root = unique_temp_dir("compile_absolute_invalid_single_file_variants"); + std::fs::create_dir_all(root.join("anchor")).expect("failed to create anchor directory"); + let canonical_invalid_single_file = root.join("dsl/sample/invalid.dag"); + std::fs::create_dir_all(&canonical_invalid_single_file) + .expect("failed to create invalid single-file target fixture directory"); + + let canonical = Command::new(daglang_bin()) + .arg("compile") + .arg(&canonical_invalid_single_file) + .current_dir(&root) + .output() + .expect("failed to run canonical absolute invalid single-file compile"); + assert!( + !canonical.status.success(), + "canonical absolute invalid single-file compile should fail" + ); + let canonical_stderr = String::from_utf8_lossy(&canonical.stderr); + assert_no_stage_failures(&canonical_stderr); + assert!( + canonical_stderr.contains(&canonical_invalid_single_file.display().to_string()), + "canonical invalid single-file diagnostic should contain normalized absolute path: {canonical_stderr}" + ); + + let variants = vec![ + ("mixed", root.join(".").join("dsl/sample/invalid.dag")), + ( + "curdir_segment_trailing", + PathBuf::from(format!("{}/./dsl/sample/invalid.dag/", root.display())), + ), + ( + "curdir_segment_double_separator", + PathBuf::from(format!("{}//./", canonical_invalid_single_file.display())), + ), + ("curdir_suffix", root.join("dsl/sample/invalid.dag/.")), + ( + "curdir_suffix_double_separator", + PathBuf::from(format!("{}//.", canonical_invalid_single_file.display())), + ), + ( + "curdir_segment_double_separator_suffix", + PathBuf::from(format!("{}//./.", canonical_invalid_single_file.display())), + ), + ( + "curdir_segment_suffix", + PathBuf::from(format!("{}/./.", canonical_invalid_single_file.display())), + ), + ("parent", root.join("anchor/../dsl/sample/invalid.dag")), + ( + "parent_curdir_segment", + root.join("anchor/.././dsl/sample/invalid.dag"), + ), + ( + "parent_curdir_segment_trailing", + PathBuf::from(format!( + "{}/anchor/.././dsl/sample/invalid.dag/", + root.display() + )), + ), + ( + "parent_curdir_segment_double_separator", + PathBuf::from(format!( + "{}/anchor/.././dsl/sample//invalid.dag", + root.display() + )), + ), + ( + "parent_curdir_segment_double_separator_trailing", + PathBuf::from(format!( + "{}/anchor//.././dsl/sample/invalid.dag/", + root.display() + )), + ), + ( + "parent_double_separator", + PathBuf::from(format!( + "{}/anchor/..//dsl/sample/invalid.dag", + root.display() + )), + ), + ( + "parent_trailing", + PathBuf::from(format!( + "{}/", + root.join("anchor/../dsl/sample/invalid.dag").display() + )), + ), + ( + "parent_double_separator_trailing", + PathBuf::from(format!( + "{}/anchor/..//dsl/sample/invalid.dag/", + root.display() + )), + ), + ( + "double_separator", + PathBuf::from(format!("{}//dsl//sample//invalid.dag", root.display())), + ), + ( + "trailing_slash", + PathBuf::from(format!("{}/", canonical_invalid_single_file.display())), + ), + ( + "double_separator_trailing", + PathBuf::from(format!("{}//dsl//sample//invalid.dag/", root.display())), + ), + ]; + + for (label, variant_path) in variants { + let variant = Command::new(daglang_bin()) + .arg("compile") + .arg(&variant_path) + .current_dir(&root) + .output() + .unwrap_or_else(|_| { + panic!("failed to run {label} absolute invalid single-file compile variant") + }); + assert!( + !variant.status.success(), + "{label} absolute invalid single-file compile variant should fail" + ); + assert_eq!( + variant.stdout, canonical.stdout, + "{label} invalid single-file variant stdout should match canonical output" + ); + assert_eq!( + variant.stderr, canonical.stderr, + "{label} invalid single-file variant stderr should match canonical output" + ); + } + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir = Command::new(daglang_bin()) + .arg("compile") + .arg("./dsl") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment root compile"); + assert!( + curdir.status.success(), + "curdir-segment root compile should succeed: {}", + String::from_utf8_lossy(&curdir.stderr) + ); + + assert_eq!( + plain.stdout, curdir.stdout, + "plain-relative and curdir-segment root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir.stderr, + "plain-relative and curdir-segment root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_trailing_slash_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_trailing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_segment_trailing = Command::new(daglang_bin()) + .arg("compile") + .arg("./dsl/") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-trailing root compile"); + assert!( + curdir_segment_trailing.status.success(), + "curdir-segment-trailing root compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_trailing.stderr) + ); + + assert_eq!( + plain.stdout, curdir_segment_trailing.stdout, + "plain-relative and curdir-segment-trailing root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_trailing.stderr, + "plain-relative and curdir-segment-trailing root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_trailing_slash_root_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_dot_double_separator_curdir_segment_trailing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_curdir_segment_trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg(".//dsl/") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-trailing-slash root compile"); + assert!( + dot_double_separator_curdir_segment_trailing_slash.status.success(), + "dot-double-separator-curdir-segment-trailing-slash root compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_curdir_segment_trailing_slash.stderr) + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_trailing_slash.stdout, + "plain-relative and dot-double-separator-curdir-segment-trailing-slash root compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_trailing_slash.stderr, + "plain-relative and dot-double-separator-curdir-segment-trailing-slash root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_suffix_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_curdir_segment_suffix_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//dsl/./.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-suffix root compile"); + assert!( + dot_double_separator_curdir_segment_suffix.status.success(), + "dot-double-separator-curdir-segment-suffix root compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_curdir_segment_suffix.stderr) + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_segment_suffix.stdout, + "plain-relative and dot-double-separator-curdir-segment-suffix root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_segment_suffix.stderr, + "plain-relative and dot-double-separator-curdir-segment-suffix root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_suffix_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_curdir_suffix_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//dsl/.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-suffix root compile"); + assert!( + dot_double_separator_curdir_suffix.status.success(), + "dot-double-separator-curdir-suffix root compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_suffix.stdout, + "plain-relative and dot-double-separator-curdir-suffix root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_suffix.stderr, + "plain-relative and dot-double-separator-curdir-suffix root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_suffix_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_suffix_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./dsl/.") + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix root compile"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix root compile should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain-relative and curdir-suffix root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain-relative and curdir-suffix root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_suffix_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_suffix_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_segment_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./dsl/./.") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-suffix root compile"); + assert!( + curdir_segment_suffix.status.success(), + "curdir-segment-suffix root compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_segment_suffix.stdout, + "plain-relative and curdir-segment-suffix root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_suffix.stderr, + "plain-relative and curdir-segment-suffix root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_suffix_double_separator_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_suffix_double_separator_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg("./dsl//.") + .current_dir(&root) + .output() + .expect("failed to run curdir-suffix-double-separator root compile"); + assert!( + curdir_suffix_double_separator.status.success(), + "curdir-suffix-double-separator root compile should succeed: {}", + String::from_utf8_lossy(&curdir_suffix_double_separator.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix_double_separator.stdout, + "plain-relative and curdir-suffix-double-separator root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix_double_separator.stderr, + "plain-relative and curdir-suffix-double-separator root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_double_separator_suffix_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_double_separator_suffix_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg("./dsl//./.") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-double-separator-suffix root compile"); + assert!( + curdir_segment_double_separator_suffix.status.success(), + "curdir-segment-double-separator-suffix root compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_double_separator_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_segment_double_separator_suffix.stdout, + "plain-relative and curdir-segment-double-separator-suffix root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_double_separator_suffix.stderr, + "plain-relative and curdir-segment-double-separator-suffix root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_double_separator_suffix_root_matches_plain_relative_output( +) { + let root = + unique_temp_dir("compile_dot_double_separator_curdir_segment_double_separator_suffix_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("compile") + .arg(".//dsl//./.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-double-separator-suffix root compile"); + assert!( + dot_double_separator_curdir_segment_double_separator_suffix + .status + .success(), + "dot-double-separator-curdir-segment-double-separator-suffix root compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_curdir_segment_double_separator_suffix.stderr) + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_double_separator_suffix.stdout, + "plain-relative and dot-double-separator-curdir-segment-double-separator-suffix root compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_double_separator_suffix.stderr, + "plain-relative and dot-double-separator-curdir-segment-double-separator-suffix root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_suffix_double_separator_root_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_dot_double_separator_curdir_suffix_double_separator_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//dsl//.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-suffix-double-separator root compile"); + assert!( + dot_double_separator_curdir_suffix_double_separator + .status + .success(), + "dot-double-separator-curdir-suffix-double-separator root compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_curdir_suffix_double_separator.stderr) + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_suffix_double_separator.stdout, + "plain-relative and dot-double-separator-curdir-suffix-double-separator root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_suffix_double_separator.stderr, + "plain-relative and dot-double-separator-curdir-suffix-double-separator root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_suffix_double_separator_missing_root_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_dot_double_separator_curdir_suffix_double_separator_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let dot_double_separator_curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing_root//.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-suffix-double-separator missing-root compile"); + assert!( + !dot_double_separator_curdir_suffix_double_separator + .status + .success(), + "dot-double-separator-curdir-suffix-double-separator missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_suffix_double_separator.stdout, + "plain and dot-double-separator-curdir-suffix-double-separator missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_suffix_double_separator.stderr, + "plain and dot-double-separator-curdir-suffix-double-separator missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "dot-double-separator-curdir-suffix-double-separator missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_suffix_double_separator_non_directory_root_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_suffix_double_separator_non_directory_root", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run plain non-directory-root compile"); + assert!( + !plain.status.success(), + "plain non-directory-root compile should fail" + ); + + let dot_double_separator_curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//input.txt//.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-suffix-double-separator non-directory-root compile"); + assert!( + !dot_double_separator_curdir_suffix_double_separator + .status + .success(), + "dot-double-separator-curdir-suffix-double-separator non-directory-root compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_suffix_double_separator.stdout, + "plain and dot-double-separator-curdir-suffix-double-separator non-directory-root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_suffix_double_separator.stderr, + "plain and dot-double-separator-curdir-suffix-double-separator non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "dot-double-separator-curdir-suffix-double-separator non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_suffix_double_separator_single_file_target_matches_plain_relative_output( +) { + let root = + unique_temp_dir("compile_dot_double_separator_curdir_suffix_double_separator_single_file"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//sample/main.dag//.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-suffix-double-separator single-file compile"); + assert!( + dot_double_separator_curdir_suffix_double_separator + .status + .success(), + "dot-double-separator-curdir-suffix-double-separator single-file compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_curdir_suffix_double_separator.stderr) + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_suffix_double_separator.stdout, + "plain and dot-double-separator-curdir-suffix-double-separator single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_suffix_double_separator.stderr, + "plain and dot-double-separator-curdir-suffix-double-separator single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_suffix_double_separator_missing_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_suffix_double_separator_missing_single_file_target", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let dot_double_separator_curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing.dag//.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-suffix-double-separator missing single-file compile"); + assert!( + !dot_double_separator_curdir_suffix_double_separator + .status + .success(), + "dot-double-separator-curdir-suffix-double-separator missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_suffix_double_separator.stdout, + "plain and dot-double-separator-curdir-suffix-double-separator missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_suffix_double_separator.stderr, + "plain and dot-double-separator-curdir-suffix-double-separator missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "dot-double-separator-curdir-suffix-double-separator missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_suffix_double_separator_invalid_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir( + "compile_dot_double_separator_curdir_suffix_double_separator_invalid_single_file_target", + ); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let dot_double_separator_curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//invalid.dag//.") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-suffix-double-separator invalid single-file compile"); + assert!( + !dot_double_separator_curdir_suffix_double_separator + .status + .success(), + "dot-double-separator-curdir-suffix-double-separator invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_curdir_suffix_double_separator.stdout, + "plain and dot-double-separator-curdir-suffix-double-separator invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_curdir_suffix_double_separator.stderr, + "plain and dot-double-separator-curdir-suffix-double-separator invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "dot-double-separator-curdir-suffix-double-separator invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//dsl") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator root compile"); + assert!( + dot_double_separator.status.success(), + "dot-double-separator root compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator.stderr) + ); + + assert_eq!( + plain.stdout, dot_double_separator.stdout, + "plain-relative and dot-double-separator root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator.stderr, + "plain-relative and dot-double-separator root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let dot_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing_root") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator missing-root compile"); + assert!( + !dot_double_separator.status.success(), + "dot-double-separator missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator.stdout, + "plain and dot-double-separator missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator.stderr, + "plain and dot-double-separator missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "dot-double-separator missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_non_directory_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_non_directory_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run plain non-directory-root compile"); + assert!( + !plain.status.success(), + "plain non-directory-root compile should fail" + ); + + let dot_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//input.txt") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator non-directory-root compile"); + assert!( + !dot_double_separator.status.success(), + "dot-double-separator non-directory-root compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator.stdout, + "plain and dot-double-separator non-directory-root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator.stderr, + "plain and dot-double-separator non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "dot-double-separator non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator single-file compile"); + assert!( + dot_double_separator.status.success(), + "dot-double-separator single-file compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator.stderr) + ); + + assert_eq!( + plain.stdout, dot_double_separator.stdout, + "plain and dot-double-separator single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator.stderr, + "plain and dot-double-separator single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_missing_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_missing_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let dot_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing.dag") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator missing single-file compile"); + assert!( + !dot_double_separator.status.success(), + "dot-double-separator missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator.stdout, + "plain and dot-double-separator missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator.stderr, + "plain and dot-double-separator missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "dot-double-separator missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_invalid_single_file_target_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_invalid_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let dot_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator invalid single-file compile"); + assert!( + !dot_double_separator.status.success(), + "dot-double-separator invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator.stdout, + "plain and dot-double-separator invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator.stderr, + "plain and dot-double-separator invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "dot-double-separator invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_double_separator_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_double_separator_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//dsl//") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-double-separator root compile"); + assert!( + dot_double_separator_double_separator.status.success(), + "dot-double-separator-double-separator root compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_double_separator.stderr) + ); + + assert_eq!( + plain.stdout, dot_double_separator_double_separator.stdout, + "plain-relative and dot-double-separator-double-separator root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_double_separator.stderr, + "plain-relative and dot-double-separator-double-separator root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_double_separator_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_double_separator_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let dot_double_separator_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing_root//") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-double-separator missing-root compile"); + assert!( + !dot_double_separator_double_separator.status.success(), + "dot-double-separator-double-separator missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_double_separator.stdout, + "plain and dot-double-separator-double-separator missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_double_separator.stderr, + "plain and dot-double-separator-double-separator missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "dot-double-separator-double-separator missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_double_separator_non_directory_root_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_dot_double_separator_double_separator_non_directory_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run plain non-directory-root compile"); + assert!( + !plain.status.success(), + "plain non-directory-root compile should fail" + ); + + let dot_double_separator_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//input.txt//") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-double-separator non-directory-root compile"); + assert!( + !dot_double_separator_double_separator.status.success(), + "dot-double-separator-double-separator non-directory-root compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_double_separator.stdout, + "plain and dot-double-separator-double-separator non-directory-root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_double_separator.stderr, + "plain and dot-double-separator-double-separator non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "dot-double-separator-double-separator non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_double_separator_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_dot_double_separator_double_separator_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//sample/main.dag//") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-double-separator single-file compile"); + assert!( + dot_double_separator_double_separator.status.success(), + "dot-double-separator-double-separator single-file compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_double_separator.stderr) + ); + + assert_eq!( + plain.stdout, dot_double_separator_double_separator.stdout, + "plain and dot-double-separator-double-separator single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_double_separator.stderr, + "plain and dot-double-separator-double-separator single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_double_separator_missing_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_dot_double_separator_double_separator_missing_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing.dag"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing.dag") + .current_dir(&root) + .output() + .expect("failed to run plain missing single-file compile"); + assert!( + !plain.status.success(), + "plain missing single-file compile should fail" + ); + + let dot_double_separator_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing.dag//") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-double-separator missing single-file compile"); + assert!( + !dot_double_separator_double_separator.status.success(), + "dot-double-separator-double-separator missing single-file compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_double_separator.stdout, + "plain and dot-double-separator-double-separator missing single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_double_separator.stderr, + "plain and dot-double-separator-double-separator missing single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", missing.display())), + "dot-double-separator-double-separator missing single-file diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_double_separator_invalid_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_dot_double_separator_double_separator_invalid_single_file_target"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let invalid_target = root.join("invalid.dag"); + std::fs::create_dir_all(&invalid_target) + .expect("failed to create invalid single-file target directory"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("invalid.dag") + .current_dir(&root) + .output() + .expect("failed to run plain invalid single-file compile"); + assert!( + !plain.status.success(), + "plain invalid single-file compile should fail" + ); + + let dot_double_separator_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//invalid.dag//") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-double-separator invalid single-file compile"); + assert!( + !dot_double_separator_double_separator.status.success(), + "dot-double-separator-double-separator invalid single-file compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_double_separator.stdout, + "plain and dot-double-separator-double-separator invalid single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_double_separator.stderr, + "plain and dot-double-separator-double-separator invalid single-file compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", invalid_target.display())), + "dot-double-separator-double-separator invalid single-file diagnostic should include normalized absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_trailing_slash_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_trailing_slash_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg(".//dsl/") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-trailing-slash root compile"); + assert!( + dot_double_separator_trailing_slash.status.success(), + "dot-double-separator-trailing-slash root compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_trailing_slash.stderr) + ); + + assert_eq!( + plain.stdout, dot_double_separator_trailing_slash.stdout, + "plain-relative and dot-double-separator-trailing-slash root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_trailing_slash.stderr, + "plain-relative and dot-double-separator-trailing-slash root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_trailing_slash_missing_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_dot_double_separator_trailing_slash_missing_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let missing = root.join("missing_root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("missing_root") + .current_dir(&root) + .output() + .expect("failed to run plain missing-root compile"); + assert!(!plain.status.success(), "plain missing-root compile should fail"); + + let dot_double_separator_trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg(".//missing_root/") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-trailing-slash missing-root compile"); + assert!( + !dot_double_separator_trailing_slash.status.success(), + "dot-double-separator-trailing-slash missing-root compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_trailing_slash.stdout, + "plain and dot-double-separator-trailing-slash missing-root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_trailing_slash.stderr, + "plain and dot-double-separator-trailing-slash missing-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&missing.display().to_string()), + "dot-double-separator-trailing-slash missing-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_trailing_slash_non_directory_root_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_dot_double_separator_trailing_slash_non_directory_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let root_file = root.join("input.txt"); + std::fs::write(&root_file, "not a directory").expect("failed to write non-directory root"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("input.txt") + .current_dir(&root) + .output() + .expect("failed to run plain non-directory-root compile"); + assert!( + !plain.status.success(), + "plain non-directory-root compile should fail" + ); + + let dot_double_separator_trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg(".//input.txt/") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-trailing-slash non-directory-root compile"); + assert!( + !dot_double_separator_trailing_slash.status.success(), + "dot-double-separator-trailing-slash non-directory-root compile should fail" + ); + + assert_eq!( + plain.stdout, dot_double_separator_trailing_slash.stdout, + "plain and dot-double-separator-trailing-slash non-directory-root compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_trailing_slash.stderr, + "plain and dot-double-separator-trailing-slash non-directory-root compile stderr should match" + ); + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&root_file.display().to_string()), + "dot-double-separator-trailing-slash non-directory-root diagnostic should normalize to absolute path: {stderr}" + ); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_trailing_slash_single_file_target_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_dot_double_separator_trailing_slash_single_file_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp root"); + let source = root.join("sample/main.dag"); + std::fs::write(&source, "module sample.main\nfn run() -> Unit { }") + .expect("failed to write single-file source"); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("sample/main.dag") + .current_dir(&root) + .output() + .expect("failed to run plain single-file compile"); + assert!( + plain.status.success(), + "plain single-file compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg(".//sample/main.dag/") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-trailing-slash single-file compile"); + assert!( + dot_double_separator_trailing_slash.status.success(), + "dot-double-separator-trailing-slash single-file compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_trailing_slash.stderr) + ); + + assert_eq!( + plain.stdout, dot_double_separator_trailing_slash.stdout, + "plain and dot-double-separator-trailing-slash single-file compile stdout should match" + ); + assert_eq!( + plain.stderr, dot_double_separator_trailing_slash.stderr, + "plain and dot-double-separator-trailing-slash single-file compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_segment_double_separator_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_segment_double_separator_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg("./dsl//") + .current_dir(&root) + .output() + .expect("failed to run curdir-segment-double-separator root compile"); + assert!( + curdir_segment_double_separator.status.success(), + "curdir-segment-double-separator root compile should succeed: {}", + String::from_utf8_lossy(&curdir_segment_double_separator.stderr) + ); + + assert_eq!( + plain.stdout, curdir_segment_double_separator.stdout, + "plain-relative and curdir-segment-double-separator root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_segment_double_separator.stderr, + "plain-relative and curdir-segment-double-separator root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_dot_double_separator_curdir_segment_double_separator_root_matches_plain_relative_output( +) { + let root = unique_temp_dir("compile_dot_double_separator_curdir_segment_double_separator_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let dot_double_separator_curdir_segment_double_separator = Command::new(daglang_bin()) + .arg("compile") + .arg(".//dsl//") + .current_dir(&root) + .output() + .expect("failed to run dot-double-separator-curdir-segment-double-separator root compile"); + assert!( + dot_double_separator_curdir_segment_double_separator.status.success(), + "dot-double-separator-curdir-segment-double-separator root compile should succeed: {}", + String::from_utf8_lossy(&dot_double_separator_curdir_segment_double_separator.stderr) + ); + + assert_eq!( + plain.stdout, + dot_double_separator_curdir_segment_double_separator.stdout, + "plain-relative and dot-double-separator-curdir-segment-double-separator root compile stdout should match" + ); + assert_eq!( + plain.stderr, + dot_double_separator_curdir_segment_double_separator.stderr, + "plain-relative and dot-double-separator-curdir-segment-double-separator root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_double_separator_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_curdir_double_separator_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_double = Command::new(daglang_bin()) + .arg("compile") + .arg(".//dsl") + .current_dir(&root) + .output() + .expect("failed to run curdir-double root compile"); + assert!( + curdir_double.status.success(), + "curdir-double root compile should succeed: {}", + String::from_utf8_lossy(&curdir_double.stderr) + ); + + assert_eq!( + plain.stdout, curdir_double.stdout, + "plain-relative and curdir-double root compile stdout should match" + ); + assert_eq!( + plain.stderr, curdir_double.stderr, + "plain-relative and curdir-double root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_trailing_slash_root_matches_plain_relative_output() { + let root = unique_temp_dir("compile_trailing_slash_root"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + write_minimal_directory_compile_fixture(&root); + + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl") + .current_dir(&root) + .output() + .expect("failed to run plain-relative root compile"); + assert!( + plain.status.success(), + "plain-relative root compile should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let trailing_slash = Command::new(daglang_bin()) + .arg("compile") + .arg("dsl/") + .current_dir(&root) + .output() + .expect("failed to run trailing-slash root compile"); + assert!( + trailing_slash.status.success(), + "trailing-slash root compile should succeed: {}", + String::from_utf8_lossy(&trailing_slash.stderr) + ); + + assert_eq!( + plain.stdout, trailing_slash.stdout, + "plain-relative and trailing-slash root compile stdout should match" + ); + assert_eq!( + plain.stderr, trailing_slash.stderr, + "plain-relative and trailing-slash root compile stderr should match" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&plain.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_single_file_fails_on_duplicate_definition() { + let fixture = unique_temp_file("compile_single_file_duplicate_definition"); + std::fs::write( + &fixture, + r#"module sample.single +fn run() -> String { "a" } +fn run() -> String { "b" } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for duplicate-definition fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on duplicate definition" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate definition `run` in module `sample.single`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_duplicate_callable_does_not_report_ambiguous_call_target() { + let fixture = unique_temp_file("compile_single_file_duplicate_callable_relaxed"); + std::fs::write( + &fixture, + r#"module sample.single +fn helper() -> String { "a" } +fn helper() -> String { "b" } +fn run() -> String { helper() } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for duplicate-callable fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on duplicate callable definition" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate definition `helper` in module `sample.single`")); + assert!( + !stderr.contains("ambiguous call target `helper`"), + "single-file relaxed mode should not report ambiguous call-target diagnostics: {stderr}" + ); + assert!( + !stderr.contains("lower error"), + "single-file relaxed duplicate-callable path should not fail in lowering: {stderr}" + ); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_duplicate_service_does_not_report_ambiguous_service_call() { + let fixture = unique_temp_file("compile_single_file_duplicate_service_relaxed"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for duplicate-service fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on duplicate service definition" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate definition `FsStorage` in module `sample.single`")); + assert!( + !stderr.contains("ambiguous service call `FsStorage.read`"), + "single-file relaxed mode should not report ambiguous service-call diagnostics: {stderr}" + ); + assert!( + !stderr.contains("lower error"), + "single-file relaxed duplicate-service path should not fail in lowering: {stderr}" + ); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_fails_on_duplicate_parameter() { + let fixture = unique_temp_file("compile_single_file_duplicate_parameter"); + std::fs::write( + &fixture, + r#"module sample.single +fn run(a: String, a: Int) -> String { a } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for duplicate-parameter fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on duplicate parameter" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate parameter `a` in `run`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_fails_on_duplicate_output_field() { + let fixture = unique_temp_file("compile_single_file_duplicate_output_field"); + std::fs::write( + &fixture, + r#"module sample.single +func run() -> { ok: Bool, ok: String } { return { ok: true } } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for duplicate-output fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on duplicate output field" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate output field `ok` in `run`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_fails_on_duplicate_uses_binding() { + let fixture = unique_temp_file("compile_single_file_duplicate_uses_binding"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { capability read { input { path: String } output { body: String } } } +func run() -> { ok: Bool } uses fs: Storage uses fs: Storage { return { ok: true } } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for duplicate-uses fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on duplicate uses binding" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate uses binding `fs` in `run`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_duplicate_resource_uses_does_not_report_ambiguous_used_type() { + let fixture = unique_temp_file("compile_single_file_duplicate_resource_uses_relaxed"); + std::fs::write( + &fixture, + r#"module sample.single +resource SharedResource {} +resource SharedResource {} +func run() -> { ok: Bool } uses fs: SharedResource { return { ok: true } } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for duplicate-resource-uses fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on duplicate resource definition" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains( + "duplicate definition `SharedResource` in module `sample.single`" + )); + assert!( + !stderr.contains("ambiguous used resource type `SharedResource`"), + "single-file relaxed mode should suppress ambiguous used resource diagnostics: {stderr}" + ); + assert!( + !stderr.contains("lower error"), + "single-file relaxed duplicate-resource uses path should not fail in lowering: {stderr}" + ); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_fails_on_duplicate_provides_binding() { + let fixture = unique_temp_file("compile_single_file_duplicate_provides_binding"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { capability read { input { path: String } output { body: String } } } +func run() -> { ok: Bool } provides out: Storage provides out: Storage { return { ok: true } } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for duplicate-provides fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on duplicate provides binding" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate provides binding `out` in `run`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_duplicate_resource_provides_does_not_report_ambiguous_provided_type() { + let fixture = unique_temp_file("compile_single_file_duplicate_resource_provides_relaxed"); + std::fs::write( + &fixture, + r#"module sample.single +resource SharedResource {} +resource SharedResource {} +func run() -> { ok: Bool } provides out: SharedResource { return { ok: true } } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for duplicate-resource-provides fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on duplicate resource definition" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains( + "duplicate definition `SharedResource` in module `sample.single`" + )); + assert!( + !stderr.contains("ambiguous provided resource type `SharedResource`"), + "single-file relaxed mode should suppress ambiguous provided resource diagnostics: {stderr}" + ); + assert!( + !stderr.contains("lower error"), + "single-file relaxed duplicate-resource provides path should not fail in lowering: {stderr}" + ); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_fails_on_use_provide_binding_conflict() { + let fixture = unique_temp_file("compile_single_file_use_provide_binding_conflict"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { capability read { input { path: String } output { body: String } } } +func run() -> { ok: Bool } uses io: Storage provides io: Storage { return { ok: true } } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for use/provide-conflict fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on use/provide binding conflict" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("binding `io` is declared in both uses/provides in `run`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_fails_on_missing_resource_capability() { + let fixture = unique_temp_file("compile_single_file_missing_resource_capability"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } + capability write { + input { path: String, body: String } + output { ok: Bool } + } +} +resource Disk implements Storage { + capability read { + input { path: String } + output { body: String } + } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for missing-resource-capability fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on missing resource capability" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("resource `Disk` is missing capability `write` for interface `Storage`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_fails_on_missing_service_operation() { + let fixture = unique_temp_file("compile_single_file_missing_service_operation"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } + capability write { + input { path: String, body: String } + output { ok: Bool } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for missing-service-operation fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on missing service operation" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("service `FsStorage` is missing operation `write` for interface `Storage`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_fails_on_interface_signature_mismatch() { + let fixture = unique_temp_file("compile_single_file_interface_signature_mismatch"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: Int) -> { body: String } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for service-signature-mismatch fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on service signature mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("`FsStorage` does not match `Storage.read` contract")); + assert!(stderr.contains("expected `String` but found `Int`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_fails_on_resource_interface_signature_mismatch() { + let fixture = unique_temp_file("compile_single_file_resource_signature_mismatch"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +resource Disk implements Storage { + capability read { + input { path: Int } + output { body: String } + } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for resource-signature-mismatch fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on resource signature mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("`Disk` does not match `Storage.read` contract")); + assert!(stderr.contains("expected `String` but found `Int`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_unresolved_service_call_reports_lower_error() { + let fixture = unique_temp_file("compile_unresolved_service_single_file"); + std::fs::write( + &fixture, + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = MissingStorage.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for unresolved service call fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail for unresolved service call" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_lower_stage_failure(&stderr); + assert!(stderr.contains("lower error: unresolved service call")); + assert!(stderr.contains("MissingStorage.read")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_accepts_resource_bound_capability_calls() { + let fixture = unique_temp_file("compile_resource_bound_capability_single_file"); + std::fs::write( + &fixture, + r#"module sample.resources +resource Filesystem { + capability read { + input { path: String } + output { body: String } + } +} +func run(path: String) -> { body: String } uses fs: Filesystem { + let response = fs.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for resource-bound capability fixture"); + + assert!( + output.status.success(), + "single-file compile should accept resource-bound capability calls: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_directory_mode_accepts_resource_bound_capability_calls() { + let root = unique_temp_dir("resource_bound_capability_directory_mode"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource Filesystem { + capability read { + input { path: String } + output { body: String } + } +} +func run(path: String) -> { body: String } uses fs: Filesystem { + let response = fs.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept resource-bound capability calls: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_single_file_unresolved_uses_reports_lower_error() { + let fixture = unique_temp_file("compile_unresolved_uses_single_file"); + std::fs::write( + &fixture, + r#"module sample.uses +func run() -> { ok: Bool } uses fs: MissingResource { + return { ok: true } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for unresolved uses fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail for unresolved uses binding" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_lower_stage_failure(&stderr); + assert!(stderr.contains("lower error: unresolved used resource")); + assert!(stderr.contains("fs: MissingResource")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_unresolved_provides_reports_lower_error() { + let fixture = unique_temp_file("compile_unresolved_provides_single_file"); + std::fs::write( + &fixture, + r#"module sample.provides +func run() -> { ok: Bool } provides out: MissingResource { + return { ok: true } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for unresolved provides fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail for unresolved provides binding" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_lower_stage_failure(&stderr); + assert!(stderr.contains("lower error: unresolved provided resource")); + assert!(stderr.contains("out: MissingResource")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_accepts_uses_resource_with_runtime_config_suffix() { + let fixture = unique_temp_file("compile_single_file_uses_config_suffix"); + std::fs::write( + &fixture, + r#"module sample.uses +resource Filesystem {} +func run() -> { ok: Bool } uses fs: Filesystem(mode: ReadWrite) { + return { ok: true } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for configured uses fixture"); + + assert!( + output.status.success(), + "single-file compile should accept configured uses resource type: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_accepts_provides_resource_with_runtime_config_suffix() { + let fixture = unique_temp_file("compile_single_file_provides_config_suffix"); + std::fs::write( + &fixture, + r#"module sample.provides +resource ArtifactStore { + release { + let done = true + } +} +func run() -> { ok: Bool } provides out: ArtifactStore(kind: temporary) { + return { ok: true } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for configured provides fixture"); + + assert!( + output.status.success(), + "single-file compile should accept configured provides resource type: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_allows_unresolved_imports() { + let fixture = unique_temp_file("compile_single_file_unresolved_import"); + std::fs::write( + &fixture, + r#"module sample.single +import missing.dep +fn run() -> Unit {} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for unresolved import fixture"); + + assert!( + output.status.success(), + "single-file compile should tolerate unresolved imports: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("typecheck errors"), + "relaxed unresolved-import path should not emit typecheck failures: {stderr}" + ); + assert!( + !stderr.contains("lower error"), + "relaxed unresolved-import path should not emit lower-stage failures: {stderr}" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Compiled 1 module(s)")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_allows_unit_return_without_tail_expression() { + let fixture = unique_temp_file("compile_single_file_unit_missing_tail"); + std::fs::write( + &fixture, + r#"module sample.single +fn run() -> Unit { let x = 42 } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for unit-missing-tail fixture"); + + assert!( + output.status.success(), + "single-file compile should allow missing tail for Unit return: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("typecheck errors"), + "Unit-return success path should not emit typecheck failures: {stderr}" + ); + assert!( + !stderr.contains("lower error"), + "Unit-return success path should not emit lower-stage failures: {stderr}" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Compiled 1 module(s)")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_allows_unresolved_call_targets() { + let fixture = unique_temp_file("compile_single_file_unresolved_call_target"); + std::fs::write( + &fixture, + r#"module sample.calls +fn run() -> String { missing(value: "ok") } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for unresolved call-target fixture"); + + assert!( + output.status.success(), + "single-file compile should tolerate unresolved call targets: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("typecheck errors"), + "relaxed unresolved-call path should not emit typecheck failures: {stderr}" + ); + assert!( + !stderr.contains("lower error"), + "relaxed unresolved-call path should not emit lower-stage failures: {stderr}" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Compiled 1 module(s)")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_unresolved_service_interface_reports_typecheck_error() { + let fixture = unique_temp_file("compile_single_file_unresolved_service_interface"); + std::fs::write( + &fixture, + r#"module sample.services +service FsStorage implements MissingStorage { + operation read(path: String) -> { body: String } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for unresolved service-interface fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on unresolved service interface" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("`FsStorage` references unresolved interface `MissingStorage`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_unresolved_resource_interface_reports_typecheck_error() { + let fixture = unique_temp_file("compile_single_file_unresolved_resource_interface"); + std::fs::write( + &fixture, + r#"module sample.resources +resource Disk implements MissingStorage { + capability read { + input { path: String } + output { body: String } + } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for unresolved resource-interface fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail on unresolved resource interface" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("`Disk` references unresolved interface `MissingStorage`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_single_file_duplicate_interface_reports_ambiguous_implements() { + let fixture = unique_temp_file("compile_single_file_duplicate_interface_ambiguous_impl"); + std::fs::write( + &fixture, + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for duplicate-interface fixture"); + + assert!( + !output.status.success(), + "single-file compile should fail for duplicate interface definitions" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate definition `Storage` in module `sample.single`")); + assert!(stderr.contains("`FsStorage` references ambiguous interface `Storage`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn expand_command_shows_lowered_nodes_and_edges() { + let output = Command::new(daglang_bin()) + .arg("expand") + .arg(makegen_file()) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang expand"); + + assert!( + output.status.success(), + "expand command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Nodes:")); + assert!(stdout.contains("tools.makegen::render_makefile")); + assert!(stdout.contains("tools.makegen::makegen")); +} + +#[test] +fn manifest_command_shows_derived_progress_manifest() { + let output = Command::new(daglang_bin()) + .arg("manifest") + .arg(makegen_file()) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang manifest"); + + assert!( + output.status.success(), + "manifest command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("ProgressManifest:")); + assert!(stdout.contains("total_nodes:")); + assert!(stdout.contains("waves:")); + assert!(stdout.contains("TestObligations:")); + assert!(stdout.contains("service_transport_prepare_targets:")); + assert!(stdout.contains("service_param_source_targets:")); + assert!(stdout.contains("resource_provide_targets:")); +} + +#[test] +fn manifest_command_reports_non_zero_transport_and_lifecycle_obligations() { + let fixture = unique_temp_file("manifest_obligations"); + std::fs::write( + &fixture, + r#"module sample.obligations +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +resource TempFile { + acquire { + let path = "/tmp/file" + } + release { + let done = true + } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("manifest") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang manifest on fixture"); + + assert!( + output.status.success(), + "manifest command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("service_transport_prepare_targets: 1")); + assert!(stdout.contains("service_transport_execute_targets: 1")); + assert!(stdout.contains("service_transport_parse_targets: 1")); + assert!(stdout.contains("service_param_source_targets: 1")); + assert!(stdout.contains("resource_acquire_targets: 1")); + assert!(stdout.contains("resource_release_targets: 1")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn manifest_command_reports_zero_service_param_source_targets_for_literal_args() { + let fixture = unique_temp_file("manifest_param_sources_zero"); + std::fs::write( + &fixture, + r#"module sample.literal +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run() -> { body: String } { + let response = FsStorage.read(path: "README.md") + return { body: response.body } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("manifest") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang manifest on fixture"); + + assert!( + output.status.success(), + "manifest command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("service_param_source_targets: 0")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn manifest_command_interface_only_provides_has_no_release_obligation() { + let fixture = unique_temp_file("manifest_interface_provides"); + std::fs::write( + &fixture, + r#"module sample.provides +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +func run() -> { ok: Bool } provides out: Storage { + return { ok: true } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("manifest") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang manifest on fixture"); + + assert!( + output.status.success(), + "manifest command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("resource_provide_targets: 1")); + assert!(stdout.contains("resource_release_targets: 0")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn viz_command_renders_mermaid_for_compiled_file() { + let output = Command::new(daglang_bin()) + .arg("viz") + .arg(makegen_file()) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang viz file"); + + assert!( + output.status.success(), + "viz command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("flowchart TB")); + assert!(stdout.contains("tools.makegen::render_makefile")); +} + +#[test] +fn compile_command_reports_diagnostics_for_invalid_file() { + let broken = unique_temp_file("broken"); + std::fs::write(&broken, "module sample.broken\nfn broken( -> String {") + .expect("failed to write broken source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&broken) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for broken file"); + + assert!(!output.status.success(), "broken source should fail compile"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + assert!(stderr.contains("compile diagnostics")); + assert!(stderr.contains(":2:")); + + std::fs::remove_file(broken).expect("failed to remove temp broken source"); +} + +#[test] +fn compile_command_reports_file_line_col_for_broken_file() { + let broken_file = unique_temp_file("compile_broken_line_col"); + std::fs::write( + &broken_file, + "module tmp.bad\nfn broken( -> String {\n \"oops\"\n}\n", + ) + .expect("failed to create broken dag file"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&broken_file) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for broken file"); + + assert!(!output.status.success(), "broken file should fail compile"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + assert!(stderr.contains("compile diagnostics")); + assert!( + stderr.contains(":2:12:"), + "expected file:line:col in stderr, got: {stderr}" + ); + + std::fs::remove_file(broken_file).expect("failed to remove broken dag file"); +} + +#[test] +fn compile_command_reports_lex_diagnostic_for_unknown_character() { + let broken_file = unique_temp_file("compile_lex_unknown_character"); + std::fs::write(&broken_file, "module tmp.lex\n$\n").expect("failed to create lex-invalid file"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&broken_file) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile for lex-invalid file"); + + assert!(!output.status.success(), "lex-invalid file should fail compile"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + assert!(stderr.contains("compile diagnostics")); + assert!(stderr.contains("unexpected character '$'")); + assert!( + stderr.contains(":2:1:"), + "expected lex diagnostic line/column in stderr, got: {stderr}" + ); + + std::fs::remove_file(broken_file).expect("failed to remove lex-invalid file"); +} + +#[test] +fn compile_command_directory_mode_aggregates_multiple_file_diagnostics() { + let root = unique_temp_dir("compile_directory_mode_errors"); + std::fs::create_dir_all(&root).expect("failed to create temp dir"); + std::fs::write(root.join("broken_a.dag"), "module sample.a\nfn") + .expect("failed to write broken_a"); + std::fs::write(root.join("broken_b.dag"), "module sample.b\nimport") + .expect("failed to write broken_b"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail when multiple files are invalid" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + assert!(stderr.contains("compile diagnostics")); + assert!(stderr.contains("broken_a.dag")); + assert!(stderr.contains("broken_b.dag")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_outputs_deterministic_diagnostic_order() { + let root = unique_temp_dir("compile_directory_mode_order"); + std::fs::create_dir_all(&root).expect("failed to create temp dir"); + std::fs::write(root.join("z_broken.dag"), "module sample.z\nfn") + .expect("failed to write z_broken"); + std::fs::write(root.join("a_broken.dag"), "module sample.a\nfn") + .expect("failed to write a_broken"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail when files are invalid" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + let first_diagnostic_line = stderr + .lines() + .find(|line| line.contains(".dag:")) + .expect("expected at least one diagnostic line with file path"); + assert!( + first_diagnostic_line.contains("a_broken.dag"), + "diagnostics should be deterministically sorted by path: {stderr}" + ); + assert!(stderr.contains("z_broken.dag")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_diagnostic_output_is_deterministic_for_same_input() { + let root = unique_temp_dir("compile_output_deterministic"); + std::fs::create_dir_all(&root).expect("failed to create temp dir"); + std::fs::write(root.join("z_lex.dag"), "module sample.lex\n$\n") + .expect("failed to write z_lex"); + std::fs::write(root.join("a_parse.dag"), "module sample.parse\nfn") + .expect("failed to write a_parse"); + + let first = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run first daglang compile"); + assert!(!first.status.success(), "first compile run should fail"); + + let second = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run second daglang compile"); + assert!(!second.status.success(), "second compile run should fail"); + + assert_eq!( + first.stderr, second.stderr, + "compile diagnostics should be deterministic for identical inputs" + ); + assert_eq!( + first.stdout, second.stdout, + "compile stdout should be deterministic for identical inputs" + ); + assert_no_stage_failures(&String::from_utf8_lossy(&first.stderr)); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_sorts_lex_diagnostics_before_parse_diagnostics() { + let root = unique_temp_dir("compile_kind_order"); + std::fs::create_dir_all(&root).expect("failed to create temp dir"); + std::fs::write(root.join("a_parse.dag"), "module sample.parse\nfn") + .expect("failed to write parse-error file"); + std::fs::write(root.join("z_lex.dag"), "module sample.lex\n$\n") + .expect("failed to write lex-error file"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on mixed-error directory"); + + assert!( + !output.status.success(), + "compile should fail when diagnostics are present" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + let first_diagnostic_line = stderr + .lines() + .find(|line| line.contains(".dag:")) + .expect("expected at least one diagnostic line"); + assert!( + first_diagnostic_line.contains("z_lex.dag"), + "lex diagnostics should sort before parse diagnostics: {stderr}" + ); + assert!(stderr.contains("a_parse.dag")); + assert!(stderr.contains("unexpected character '$'")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_single_file_mode_ignores_sibling_broken_files() { + let root = unique_temp_dir("compile_single_file_ignores_sibling_broken"); + std::fs::create_dir_all(&root).expect("failed to create temp dir"); + let good_file = root.join("good.dag"); + std::fs::write(&good_file, "module sample.good\nfn ok() -> Unit {}") + .expect("failed to write good file"); + std::fs::write(root.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write broken sibling"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&good_file) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on single file"); + + assert!( + output.status.success(), + "single-file compile should succeed even with sibling broken file: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + assert!( + output.stderr.is_empty(), + "single-file compile should not emit stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_empty_directory_reports_lower_stage_error() { + let root = unique_temp_dir("compile_empty_directory_lower_error"); + std::fs::create_dir_all(&root).expect("failed to create temp dir"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on empty directory"); + + assert!( + !output.status.success(), + "compile should fail for empty directory roots" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_lower_stage_failure(&stderr); + assert!(stderr.contains("no callable or pipeline declarations to lower")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_non_dag_only_directory_reports_lower_stage_error() { + let root = unique_temp_dir("compile_non_dag_only_directory"); + std::fs::create_dir_all(&root).expect("failed to create temp dir"); + std::fs::write(root.join("notes.txt"), "this should be ignored") + .expect("failed to write non-dag file"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on non-dag-only directory"); + + assert!( + !output.status.success(), + "compile should fail with lower-stage error when no .dag files exist" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_lower_stage_failure(&stderr); + assert!(stderr.contains("no callable or pipeline declarations to lower")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_ignores_non_dag_files_when_dag_files_exist() { + let root = unique_temp_dir("compile_ignore_non_dag_mixed"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write(root.join("sample/main.dag"), "module sample.main\nfn run() -> Unit { }") + .expect("failed to write dag file"); + std::fs::write(root.join("notes.txt"), "module fake\n$").expect("failed to write txt file"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on mixed dag/non-dag directory"); + + assert!( + output.status.success(), + "compile should succeed when dag files exist even with non-dag siblings: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.trim().is_empty(), "compile success should not emit stderr"); + assert_no_stage_failures(&stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Compiled 1 module(s)"), + "expected compile summary to report one dag module: {stdout}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn expand_command_reports_diagnostics_for_invalid_file() { + let broken = unique_temp_file("expand_broken"); + std::fs::write(&broken, "module sample.broken\nfn broken( -> String {") + .expect("failed to write broken source"); + + let output = Command::new(daglang_bin()) + .arg("expand") + .arg(&broken) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang expand for broken file"); + + assert!(!output.status.success(), "broken source should fail expand"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + assert!(stderr.contains("compile diagnostics")); + assert!(stderr.contains(":2:")); + + std::fs::remove_file(broken).expect("failed to remove temp broken source"); +} + +#[test] +fn manifest_command_reports_diagnostics_for_invalid_file() { + let broken = unique_temp_file("manifest_broken"); + std::fs::write(&broken, "module sample.broken\nfn broken( -> String {") + .expect("failed to write broken source"); + + let output = Command::new(daglang_bin()) + .arg("manifest") + .arg(&broken) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang manifest for broken file"); + + assert!(!output.status.success(), "broken source should fail manifest"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + assert!(stderr.contains("compile diagnostics")); + assert!(stderr.contains(":2:")); + + std::fs::remove_file(broken).expect("failed to remove temp broken source"); +} + +#[test] +fn expand_command_reports_unresolved_service_call_lower_error() { + let fixture = unique_temp_file("unresolved_service_call"); + std::fs::write( + &fixture, + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = MissingStorage.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("expand") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang expand on unresolved service fixture"); + + assert!( + !output.status.success(), + "expand should fail when service call endpoint cannot be resolved" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_lower_stage_failure(&stderr); + assert!(stderr.contains("lower error: unresolved service call")); + assert!(stderr.contains("MissingStorage.read")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn expand_command_reports_unresolved_uses_lower_error() { + let fixture = unique_temp_file("unresolved_uses"); + std::fs::write( + &fixture, + r#"module sample.resources +func run() -> { ok: Bool } uses fs: MissingResource { + return { ok: true } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("expand") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang expand on unresolved uses fixture"); + + assert!( + !output.status.success(), + "expand should fail when uses target cannot be resolved" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_lower_stage_failure(&stderr); + assert!(stderr.contains("lower error: unresolved used resource")); + assert!(stderr.contains("fs: MissingResource")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn expand_command_reports_unresolved_provides_lower_error() { + let fixture = unique_temp_file("unresolved_provides"); + std::fs::write( + &fixture, + r#"module sample.resources +func run() -> { ok: Bool } provides out: MissingResource { + return { ok: true } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("expand") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang expand on unresolved provides fixture"); + + assert!( + !output.status.success(), + "expand should fail when provides target cannot be resolved" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_lower_stage_failure(&stderr); + assert!(stderr.contains("lower error: unresolved provided resource")); + assert!(stderr.contains("out: MissingResource")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn expand_command_shows_param_source_wiring_for_identifier_service_args() { + let fixture = unique_temp_file("service_param_source_expand"); + std::fs::write( + &fixture, + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("expand") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang expand on fixture"); + + assert!( + output.status.success(), + "expand command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("param_source_sample_services_run_path")); + assert!(stdout.contains( + "param_source_sample_services_run_path.path -> prepare_transport_sample_services_FsStorage_read.path" + )); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_directory_mode_fails_on_unresolved_imports() { + let root = unique_temp_dir("unresolved_import"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nimport missing.dep\nfn run() -> Unit {}", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on unresolved imports" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("unresolved import")); + assert!( + !stderr.contains("lower error"), + "unresolved imports should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_duplicate_definitions() { + let root = unique_temp_dir("duplicate_definitions"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn run() -> Unit {} +func run() -> { ok: Bool } { + return { ok: true } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on duplicate definitions" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate definition `run` in module `sample.main`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_duplicate_interface_also_reports_ambiguous_implements() { + let root = unique_temp_dir("duplicate_interface_ambiguous_implements"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { input { path: String } output { body: String } } +} +interface Storage { + capability read { input { path: String } output { body: String } } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on duplicate interface definitions" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate definition `Storage` in module `sample.main`")); + assert!(stderr.contains("`FsStorage` references ambiguous interface `Storage`")); + assert!( + !stderr.contains("lower error"), + "duplicate-interface layering should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_duplicate_service_also_reports_ambiguous_service_call() { + let root = unique_temp_dir("duplicate_service_ambiguous_call"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on duplicate service definitions" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate definition `FsStorage` in module `sample.main`")); + assert!(stderr.contains("ambiguous service call `FsStorage.read` in `run`")); + assert!( + !stderr.contains("lower error"), + "duplicate-service layering should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_duplicate_output_fields() { + let root = unique_temp_dir("duplicate_output_fields"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +func run() -> { ok: Bool, ok: Bool } { + return { ok: true } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on duplicate output fields" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate output field `ok` in `run`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_duplicate_parameters() { + let root = unique_temp_dir("duplicate_parameters"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfn run(a: String, a: Int) -> String { a }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on duplicate parameters" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate parameter `a` in `run`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_ambiguous_interface_reference() { + let root = unique_temp_dir("ambiguous_interface_reference"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/first.dag"), + "module sample.first\ninterface Storage { capability read { input { path: String } output { body: String } } }", + ) + .expect("failed to write source"); + std::fs::write( + root.join("sample/second.dag"), + "module sample.second\ninterface Storage { capability read { input { path: String } output { body: String } } }", + ) + .expect("failed to write source"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nservice FsStorage implements Storage { operation read(path: String) -> { body: String } }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on ambiguous interface reference" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("ambiguous interface `Storage`")); + assert!( + !stderr.contains("lower error"), + "ambiguous interface references should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_ambiguous_resource_interface_reference() { + let root = unique_temp_dir("ambiguous_resource_interface_reference"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/first.dag"), + "module sample.first\ninterface Storage { capability read { input { path: String } output { body: String } } }", + ) + .expect("failed to write source"); + std::fs::write( + root.join("sample/second.dag"), + "module sample.second\ninterface Storage { capability read { input { path: String } output { body: String } } }", + ) + .expect("failed to write source"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nresource Disk implements Storage { capability read { input { path: String } output { body: String } } }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on ambiguous resource interface reference" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("`Disk` references ambiguous interface `Storage`")); + assert!( + !stderr.contains("lower error"), + "ambiguous interface references should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_unresolved_interface_reference() { + let root = unique_temp_dir("unresolved_interface_reference"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nservice FsStorage implements MissingStorage { operation read(path: String) -> { body: String } }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on unresolved interface reference" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("references unresolved interface `MissingStorage`")); + assert!( + !stderr.contains("lower error"), + "unresolved interface references should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_unresolved_resource_interface_reference() { + let root = unique_temp_dir("unresolved_resource_interface_reference"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nresource Disk implements MissingStorage { capability read { input { path: String } output { body: String } } }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on unresolved resource interface reference" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("`Disk` references unresolved interface `MissingStorage`")); + assert!( + !stderr.contains("lower error"), + "unresolved interface references should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_missing_resource_capability() { + let root = unique_temp_dir("missing_resource_capability"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } + capability write { + input { path: String, body: String } + output { ok: Bool } + } +} +resource Disk implements Storage { + capability read { + input { path: String } + output { body: String } + } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail when resource is missing interface capability" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("resource `Disk` is missing capability `write` for interface `Storage`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_missing_service_operation() { + let root = unique_temp_dir("missing_service_operation"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } + capability write { + input { path: String, body: String } + output { ok: Bool } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail when service is missing interface operation" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("service `FsStorage` is missing operation `write` for interface `Storage`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_interface_signature_mismatch() { + let root = unique_temp_dir("interface_signature_mismatch"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: Int) -> { body: String } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on interface signature mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("`FsStorage` does not match `Storage.read` contract")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_resource_interface_signature_mismatch() { + let root = unique_temp_dir("resource_interface_signature_mismatch"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +resource Disk implements Storage { + capability read { + input { path: Int } + output { body: String } + } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on resource interface signature mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("`Disk` does not match `Storage.read` contract")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_use_provide_binding_conflict() { + let root = unique_temp_dir("use_provide_binding_conflict"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +func run() -> { ok: Bool } uses io: Storage provides io: Storage { + return { ok: true } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on use/provide binding conflict" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("binding `io` is declared in both uses/provides in `run`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_unknown_uses_resource_type() { + let root = unique_temp_dir("unknown_uses_type"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfunc run() -> { ok: Bool } uses fs: MissingResource { return { ok: true } }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on unknown uses resource type" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("unknown used resource type `MissingResource`")); + assert!( + !stderr.contains("lower error"), + "unknown uses resource type should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_uses_resource_with_runtime_config_suffix() { + let root = unique_temp_dir("configured_uses_type"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource Filesystem {} +func run() -> { ok: Bool } uses fs: Filesystem(mode: ReadWrite) { + return { ok: true } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept configured uses resource type: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_duplicate_uses_binding() { + let root = unique_temp_dir("duplicate_uses_binding"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +func run() -> { ok: Bool } uses fs: Storage uses fs: Storage { + return { ok: true } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on duplicate uses binding" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate uses binding `fs` in `run`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_ambiguous_uses_resource_type() { + let root = unique_temp_dir("ambiguous_uses_type"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/one.dag"), + "module sample.one\nresource SharedResource {}", + ) + .expect("failed to write source"); + std::fs::write( + root.join("sample/two.dag"), + "module sample.two\nresource SharedResource {}", + ) + .expect("failed to write source"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfunc run() -> { ok: Bool } uses fs: SharedResource { return { ok: true } }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on ambiguous uses resource type" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("ambiguous used resource type `SharedResource`")); + assert!( + !stderr.contains("lower error"), + "ambiguous uses resource types should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_duplicate_resource_uses_also_reports_ambiguous_used_type() { + let root = unique_temp_dir("duplicate_resource_uses_ambiguous"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource SharedResource {} +resource SharedResource {} +func run() -> { ok: Bool } uses fs: SharedResource { return { ok: true } } +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on duplicate resource definitions" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate definition `SharedResource` in module `sample.main`")); + assert!(stderr.contains("ambiguous used resource type `SharedResource`")); + assert!( + !stderr.contains("lower error"), + "duplicate-resource uses layering should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_duplicate_provides_binding() { + let root = unique_temp_dir("duplicate_provides_binding"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +func run() -> { ok: Bool } provides out: Storage provides out: Storage { + return { ok: true } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on duplicate provides binding" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate provides binding `out` in `run`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_unknown_provides_resource_type() { + let root = unique_temp_dir("unknown_provides_type"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfunc run() -> { ok: Bool } provides out: MissingResource { return { ok: true } }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on unknown provides resource type" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("unknown provided resource type `MissingResource`")); + assert!( + !stderr.contains("lower error"), + "unknown provides resource type should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_provides_resource_with_runtime_config_suffix() { + let root = unique_temp_dir("configured_provides_type"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource ArtifactStore { + release { + let done = true + } +} +func run() -> { ok: Bool } provides out: ArtifactStore(kind: temporary) { + return { ok: true } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept configured provides resource type: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_ambiguous_provides_resource_type() { + let root = unique_temp_dir("ambiguous_provides_type"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/one.dag"), + "module sample.one\nresource SharedResource {}", + ) + .expect("failed to write source"); + std::fs::write( + root.join("sample/two.dag"), + "module sample.two\nresource SharedResource {}", + ) + .expect("failed to write source"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfunc run() -> { ok: Bool } provides out: SharedResource { return { ok: true } }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on ambiguous provides resource type" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("ambiguous provided resource type `SharedResource`")); + assert!( + !stderr.contains("lower error"), + "ambiguous provides resource types should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_duplicate_resource_provides_also_reports_ambiguous_provided_type() { + let root = unique_temp_dir("duplicate_resource_provides_ambiguous"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource SharedResource {} +resource SharedResource {} +func run() -> { ok: Bool } provides out: SharedResource { return { ok: true } } +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on duplicate resource definitions" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate definition `SharedResource` in module `sample.main`")); + assert!(stderr.contains("ambiguous provided resource type `SharedResource`")); + assert!( + !stderr.contains("lower error"), + "duplicate-resource provides layering should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_reports_call_arity_typecheck_error() { + let fixture = unique_temp_file("call_arity"); + std::fs::write( + &fixture, + r#"module sample.calls +fn fmt(value: String) -> String { value } +fn run() -> String { fmt() } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on call arity mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("call arity mismatch")); + assert!(stderr.contains("fmt")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_unknown_named_call_argument_typecheck_error() { + let fixture = unique_temp_file("call_unknown_arg"); + std::fs::write( + &fixture, + r#"module sample.calls +fn fmt(value: String) -> String { value } +fn run() -> String { fmt(text: "ok") } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on unknown named call argument" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("unknown named argument `text`")); + assert!(stderr.contains("call to `fmt`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_undefined_type_typecheck_error() { + let fixture = unique_temp_file("undefined_type"); + std::fs::write( + &fixture, + r#"module sample.types +fn run(input: MissingType) -> String { "ok" } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on undefined type" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("undefined type `MissingType")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_type_mismatch_typecheck_error() { + let fixture = unique_temp_file("type_mismatch"); + std::fs::write( + &fixture, + r#"module sample.types +fn run() -> String { return 42 } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on return type mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("type mismatch: expected `String`, got `Int`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_implicit_return_type_mismatch_typecheck_error() { + let fixture = unique_temp_file("implicit_return_type_mismatch"); + std::fs::write( + &fixture, + r#"module sample.types +fn run() -> String { 42 } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on implicit return type mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("type mismatch: expected `String`, got `Int`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_missing_tail_expression_type_mismatch_typecheck_error() { + let fixture = unique_temp_file("missing_tail_expression_type_mismatch"); + std::fs::write( + &fixture, + r#"module sample.types +fn run() -> String { let x = 42 } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail when fn has no tail expression for non-unit return type" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("type mismatch: expected `String`, got `Unit`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_no_such_field_typecheck_error() { + let fixture = unique_temp_file("no_such_field"); + std::fs::write( + &fixture, + r#"module sample.types +func run() -> { body: String } { + let payload = { body: "ok" } + return { body: payload.missing } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on missing record field access" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("type `Record` has no field `missing`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_no_such_field_for_named_record_type() { + let fixture = unique_temp_file("no_such_field_named_record"); + std::fs::write( + &fixture, + r#"module sample.types +type Payload { body: String } +fn run(input: Payload) -> String { input.missing } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on missing field access for named record type" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("type `Payload` has no field `missing`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_unsatisfiable_refinement_typecheck_error() { + let fixture = unique_temp_file("unsatisfiable_refinement"); + std::fs::write( + &fixture, + r#"module sample.types +fn run(value: Int @range(min: 5, max: 1)) -> Int { value } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on unsatisfiable refinement" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("unsatisfiable refinement on `Int`: range min 5 exceeds max 1")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_generic_arity_mismatch_typecheck_error() { + let fixture = unique_temp_file("generic_arity_mismatch"); + std::fs::write( + &fixture, + r#"module sample.types +fn run(values: Map) -> Int { 1 } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on generic arity mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("generic arity mismatch for `Map`: expected 2, got 1")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_user_defined_generic_arity_mismatch_typecheck_error() { + let fixture = unique_temp_file("user_defined_generic_arity_mismatch"); + std::fs::write( + &fixture, + r#"module sample.types +type Box = T +fn run(values: Box) -> String { values } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on user-defined generic arity mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("generic arity mismatch for `Box`: expected 1, got 2")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_service_call_arity_typecheck_error() { + let fixture = unique_temp_file("service_call_arity"); + std::fs::write( + &fixture, + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run() -> { ok: Bool } { + let response = FsStorage.read() + return { ok: true } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on service call arity mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("service call arity mismatch")); + assert!(stderr.contains("FsStorage.read")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_unknown_named_service_argument_typecheck_error() { + let fixture = unique_temp_file("service_call_unknown_arg"); + std::fs::write( + &fixture, + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(file: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on unknown named service argument" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("unknown named argument `file`")); + assert!(stderr.contains("service call `FsStorage.read`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_duplicate_named_call_argument_typecheck_error() { + let fixture = unique_temp_file("duplicate_named_call_arg"); + std::fs::write( + &fixture, + r#"module sample.calls +fn fmt(value: String, mode: String) -> String { value } +fn run() -> String { + fmt(value: "a", value: "b") +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on duplicate named call argument" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate named argument `value`")); + assert!(stderr.contains("call to `fmt`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_reports_duplicate_named_service_argument_typecheck_error() { + let fixture = unique_temp_file("duplicate_named_service_arg"); + std::fs::write( + &fixture, + r#"module sample.services +interface Storage { + capability read { + input { path: String, mode: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String, mode: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path: path, path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile"); + + assert!( + !output.status.success(), + "compile should fail on duplicate named service argument" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate named argument `path`")); + assert!(stderr.contains("service call `FsStorage.read`")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn compile_command_directory_mode_fails_on_call_arity_typecheck_error() { + let root = unique_temp_dir("call_arity"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn fmt(value: String) -> String { value } +fn run() -> String { fmt() } +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on call arity mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("call arity mismatch")); + assert!(stderr.contains("fmt")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_omitting_defaulted_call_args() { + let root = unique_temp_dir("call_default_args"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn greet(name: String, punctuation: String = "!") -> String { name } +fn run() -> String { greet(name: "hi") } +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept omitted defaulted call args: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_pattern_calls_with_extra_named_wiring_args() { + let root = unique_temp_dir("pattern_wiring_call_args"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +pattern ensure(should_act: Bool = true) -> { acted: Bool } { + return { acted: should_act } +} +fn run() -> Bool { + let result = ensure(check: true, action: false) + result.acted +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept pattern calls with extra named wiring args: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_generic_fn_type_params() { + let root = unique_temp_dir("generic_fn_type_params"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn identity(value: T) -> T { + value +} +fn relay(value: T) -> T { + identity(value: value) +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept generic fn type params: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_generic_pattern_type_params() { + let root = unique_temp_dir("generic_pattern_type_params"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +pattern passthrough(value: T) -> { value: T } { + return { value: value } +} +fn relay(value: T) -> T { + let result = passthrough(value: value) + result.value +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept generic pattern type params: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_named_record_literal_returns() { + let root = unique_temp_dir("named_record_literal_return"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type StageResult { + success: Bool, + skipped: Bool +} +fn result() -> StageResult { + { success: true, skipped: false } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept named-record literal returns: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_resource_config_named_type_returns() { + let root = unique_temp_dir("resource_config_named_return"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +resource GcsBucket { + config { + name: String, + project: String + } +} +fn gcp_dev_storage() -> GcsBucket.Config { + { name: "gunbc-dev-artifacts", project: "gunbai-auto" } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept resource config named-type returns: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_unknown_named_call_argument_typecheck_error() { + let root = unique_temp_dir("call_unknown_arg"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn fmt(value: String) -> String { value } +fn run() -> String { fmt(text: "ok") } +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on unknown named call argument" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("unknown named argument `text`")); + assert!(stderr.contains("call to `fmt`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_duplicate_named_call_argument_typecheck_error() { + let root = unique_temp_dir("duplicate_named_call_arg"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn fmt(value: String, mode: String) -> String { value } +fn run() -> String { + fmt(value: "a", value: "b") +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on duplicate named call argument" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate named argument `value`")); + assert!(stderr.contains("call to `fmt`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_service_call_arity_typecheck_error() { + let root = unique_temp_dir("service_call_arity"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run() -> { ok: Bool } { + let response = FsStorage.read() + return { ok: true } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on service call arity mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("service call arity mismatch")); + assert!(stderr.contains("FsStorage.read")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_omitting_defaulted_service_call_args() { + let root = unique_temp_dir("service_call_default_args"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { + path: String, + recursive: Bool = false + } + output { ok: Bool } + } +} +service FsStorage implements Storage { + operation read(path: String, recursive: Bool = false) -> { ok: Bool } +} +func run() -> { ok: Bool } { + let response = FsStorage.read(path: "/tmp") + return { ok: response.ok } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept omitted defaulted service call args: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_unknown_named_service_argument_typecheck_error() { + let root = unique_temp_dir("service_call_unknown_arg"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(file: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on unknown named service argument" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("unknown named argument `file`")); + assert!(stderr.contains("service call `FsStorage.read`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_duplicate_named_service_argument_typecheck_error() { + let root = unique_temp_dir("duplicate_named_service_arg"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +interface Storage { + capability read { + input { path: String, mode: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String, mode: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path: path, path: path) + return { body: response.body } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on duplicate named service argument" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate named argument `path`")); + assert!(stderr.contains("service call `FsStorage.read`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_unresolved_service_call() { + let root = unique_temp_dir("unresolved_service_call_typecheck"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +func run(path: String) -> { body: String } { + let response = MissingStorage.read(path: path) + return { body: response.body } +}"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on unresolved service call" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("unresolved service call `MissingStorage.read`")); + assert!( + !stderr.contains("lower error"), + "directory unresolved service call should fail before lowering: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_ambiguous_service_call() { + let root = unique_temp_dir("ambiguous_service_call_typecheck"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/first.dag"), + r#"module sample.first +service SharedService { + operation read(path: String) -> { body: String } +}"#, + ) + .expect("failed to write first service source"); + std::fs::write( + root.join("sample/second.dag"), + r#"module sample.second +service SharedService { + operation read(path: String) -> { body: String } +}"#, + ) + .expect("failed to write second service source"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +func run(path: String) -> { body: String } { + let response = SharedService.read(path: path) + return { body: response.body } +}"#, + ) + .expect("failed to write main source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on ambiguous service call" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("ambiguous service call `SharedService.read`")); + assert!( + !stderr.contains("lower error"), + "ambiguous service calls should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_ambiguous_callable_target() { + let root = unique_temp_dir("ambiguous_callable_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/one.dag"), + "module sample.one\nfn render(value: String) -> String { value }", + ) + .expect("failed to write first callable source"); + std::fs::write( + root.join("sample/two.dag"), + "module sample.two\nfn render(value: String) -> String { value }", + ) + .expect("failed to write second callable source"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfn run() -> String { render(value: \"ok\") }", + ) + .expect("failed to write main source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on ambiguous callable target" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("ambiguous call target `render`")); + assert!( + !stderr.contains("lower error"), + "ambiguous callable targets should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_duplicate_callable_also_reports_ambiguous_call_target() { + let root = unique_temp_dir("duplicate_callable_ambiguous_call_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn helper() -> String { "a" } +fn helper() -> String { "b" } +fn run() -> String { helper() } +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on duplicate callable definitions" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("duplicate definition `helper` in module `sample.main`")); + assert!(stderr.contains("ambiguous call target `helper` in `run`")); + assert!( + !stderr.contains("lower error"), + "duplicate-callable layering should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_unresolved_callable_target() { + let root = unique_temp_dir("unresolved_callable_target"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfn run() -> String { missing(value: \"ok\") }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on unresolved callable target" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("unresolved call target `missing`")); + assert!( + !stderr.contains("lower error"), + "unresolved callable targets should fail in typecheck stage: {stderr}" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_collection_intrinsic_call_targets() { + let root = unique_temp_dir("collection_intrinsic_call_targets"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type Stage { + success: Bool, + skipped: Bool, + name: String +} +fn summarize(stages: List) -> Int { + let passed = stages |> filter(s => s.success) |> count() + let labels = stages |> map(s => s.name) |> join(",") + let done = labels |> ends_with("ok") + passed +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept collection intrinsic call targets: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_function_typed_parameter_calls() { + let root = unique_temp_dir("function_typed_parameter_calls"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn apply(value: Int, callback: fn(Int) -> Int) -> Int { + callback(value) +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept function-typed parameter calls: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_sum_variant_constructor_calls() { + let root = unique_temp_dir("sum_variant_constructor_calls"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type CloudConfig + = GcpConfig { project: String, region: String } + | AwsConfig { region: String } + +fn make_gcp() -> CloudConfig { + GcpConfig(project: "gunbc", region: "us-central1") +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept sum variant constructor calls: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_zero_arity_variant_identifier_returns() { + let root = unique_temp_dir("zero_arity_variant_identifier_returns"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type Environment = Dev | Ci +fn env() -> Environment { + Dev +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept zero-arity variant identifier returns: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_lossy_match_body_without_tail_mismatch() { + let root = unique_temp_dir("lossy_match_body"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type CloudConfig + = GcpConfig { project: String } + | AwsConfig { account: String } +type CloudProvider = Gcp | Aws + +fn provider_of(config: CloudConfig) -> CloudProvider { + match config { + GcpConfig { ... } => Gcp + AwsConfig { ... } => Aws + } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should tolerate lossy match-body parsing without false tail mismatch: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_accepts_std_helper_intrinsic_call_targets() { + let root = unique_temp_dir("std_helper_intrinsic_call_targets"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type DocgenSources {} + +fn run(sources: DocgenSources, payload: String) -> String { + let a = "template" |> replace_section("section", "value") + let b = render_test_listings(sources: sources) + let c = render_graph_structure(sources: sources) + let d = render_source_artifacts(sources: sources) + let e = compute_topology_diff(current: "{}", base: "{}") + let f = render_annotated_mermaid(diff: e, topology: "{}", title: "title") + let g = detect_runtime() + let h = generate() + let i = now() + let j = build_token( + payload: payload, + scheme: "Bearer", + header_name: "Authorization", + source_id: "source", + required_scopes: ["gist"] + ) + a +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should accept std helper intrinsic call targets: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_type_mismatch() { + let root = unique_temp_dir("type_mismatch"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfn run() -> String { return 42 }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on return type mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("type mismatch: expected `String`, got `Int`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_implicit_return_type_mismatch() { + let root = unique_temp_dir("implicit_type_mismatch"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfn run() -> String { 42 }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on implicit return type mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("type mismatch: expected `String`, got `Int`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_allows_unit_return_without_tail_expression() { + let root = unique_temp_dir("unit_missing_tail"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit { let x = 42 }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + output.status.success(), + "directory compile should allow missing tail for Unit return: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("typecheck errors"), + "Unit-return success path should not emit typecheck failures: {stderr}" + ); + assert!( + !stderr.contains("lower error"), + "Unit-return success path should not emit lower-stage failures: {stderr}" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Compiled 1 module(s)")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_missing_tail_expression_type_mismatch() { + let root = unique_temp_dir("missing_tail_expression_type_mismatch"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfn run() -> String { let x = 42 }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail when fn has no tail expression for non-unit return type" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("type mismatch: expected `String`, got `Unit`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_no_such_field_for_record_literal() { + let root = unique_temp_dir("no_such_field_record_literal"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +func run() -> { body: String } { + let payload = { body: "ok" } + return { body: payload.missing } +} +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on missing field access for record literal" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("type `Record` has no field `missing`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_no_such_field_for_named_record() { + let root = unique_temp_dir("no_such_field_named_record"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type Payload { body: String } +fn run(input: Payload) -> String { input.missing } +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on missing field for named record type" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("type `Payload` has no field `missing`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_generic_arity_mismatch() { + let root = unique_temp_dir("generic_arity_mismatch"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn run(values: Map) -> Int { 1 } +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on generic arity mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("generic arity mismatch for `Map`: expected 2, got 1")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_unsatisfiable_refinement() { + let root = unique_temp_dir("unsatisfiable_refinement"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + "module sample.main\nfn run(value: Int @range(min: 9, max: 1)) -> Int { value }", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on unsatisfiable refinement" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("unsatisfiable refinement on `Int`: range min 9 exceeds max 1")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_undefined_type_typecheck_error() { + let root = unique_temp_dir("undefined_type"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +fn run(input: MissingType) -> String { "ok" } +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on undefined type" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("undefined type `MissingType")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_fails_on_user_defined_generic_arity_mismatch() { + let root = unique_temp_dir("user_defined_generic_arity"); + std::fs::create_dir_all(root.join("sample")).expect("failed to create temp dir"); + std::fs::write( + root.join("sample/main.dag"), + r#"module sample.main +type Box = T +fn run(value: Box) -> String { value } +"#, + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on directory"); + + assert!( + !output.status.success(), + "directory compile should fail on user-defined generic arity mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_typecheck_stage_failure(&stderr); + assert!(stderr.contains("generic arity mismatch for `Box`: expected 1, got 2")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn compile_command_directory_mode_reports_module_path_mismatch() { + let root = unique_temp_dir("path_mismatch"); + std::fs::create_dir_all(&root).expect("failed to create temp dir"); + std::fs::write( + root.join("main.dag"), + "module wrong.name\nfn run() -> Unit {}", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("compile") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang compile on mismatch directory"); + + assert!( + !output.status.success(), + "directory compile should fail on module path mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + assert!(stderr.contains("module path mismatches")); + assert!(stderr.contains("declared `wrong.name`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn expand_command_directory_mode_reports_module_path_mismatch() { + let root = unique_temp_dir("expand_path_mismatch"); + std::fs::create_dir_all(&root).expect("failed to create temp dir"); + std::fs::write( + root.join("main.dag"), + "module wrong.name\nfn run() -> Unit {}", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("expand") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang expand on mismatch directory"); + + assert!( + !output.status.success(), + "directory expand should fail on module path mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + assert!(stderr.contains("module path mismatches")); + assert!(stderr.contains("declared `wrong.name`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} + +#[test] +fn manifest_command_directory_mode_reports_module_path_mismatch() { + let root = unique_temp_dir("manifest_path_mismatch"); + std::fs::create_dir_all(&root).expect("failed to create temp dir"); + std::fs::write( + root.join("main.dag"), + "module wrong.name\nfn run() -> Unit {}", + ) + .expect("failed to write source"); + + let output = Command::new(daglang_bin()) + .arg("manifest") + .arg(&root) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang manifest on mismatch directory"); + + assert!( + !output.status.success(), + "directory manifest should fail on module path mismatch" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_no_stage_failures(&stderr); + assert!(stderr.contains("module path mismatches")); + assert!(stderr.contains("declared `wrong.name`")); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp dir"); +} diff --git a/core/daglang/daglang-derive/src/lib.rs b/core/daglang/daglang-derive/src/lib.rs index 3417b624fe1..78554c051cc 100644 --- a/core/daglang/daglang-derive/src/lib.rs +++ b/core/daglang/daglang-derive/src/lib.rs @@ -17,9 +17,477 @@ //! → ToolMetadata //! ``` +use std::collections::{BTreeMap, VecDeque}; + +use daglang_lower::LoweredOp; +use gunbc_ir::{detect_boundaries, detect_entrypoints, Dag, Node}; + +/// Derived artifacts produced from lowered GraphIR. +#[derive(Debug, Clone)] +pub struct DerivedArtifacts { + pub manifest: ProgressManifest, + pub obligations: TestObligations, + pub tool_metadata: ToolMetadata, +} + +/// Minimal progress manifest for Phase-1 compiler scaffolding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProgressManifest { + pub total_nodes: usize, + pub total_edges: usize, + pub waves: Vec>, + pub entrypoint_nodes: Vec, + pub boundary_nodes: Vec, +} + +/// Minimal obligation summary derived from graph structure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TestObligations { + pub dry_run_completion_required: bool, + pub transport_execution_targets: usize, + pub pure_node_determinism_targets: usize, + pub service_transport_prepare_targets: usize, + pub service_transport_execute_targets: usize, + pub service_transport_parse_targets: usize, + pub service_param_source_targets: usize, + pub resource_provide_targets: usize, + pub resource_acquire_targets: usize, + pub resource_release_targets: usize, +} + +/// Metadata summary for lowered modules. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolMetadata { + pub modules: Vec, +} + +/// Module-level metadata counts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModuleMetadata { + pub module: String, + pub callable_count: usize, + pub pipeline_count: usize, +} + /// Errors during derivation. #[derive(Debug)] pub enum DeriveError { /// The IR graph is not valid for manifest derivation. InvalidGraph(String), } + +impl std::fmt::Display for DeriveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidGraph(reason) => write!(f, "invalid graph for derivation: {reason}"), + } + } +} + +/// Derive manifest, obligations, and metadata from a lowered daglang graph. +pub fn derive_artifacts(dag: &Dag) -> Result { + if dag.nodes.is_empty() { + return Err(DeriveError::InvalidGraph( + "cannot derive artifacts for an empty graph".to_string(), + )); + } + + let manifest = ProgressManifest { + total_nodes: dag.nodes.len(), + total_edges: dag.edges.len(), + waves: compute_waves(dag)?, + entrypoint_nodes: { + let mut nodes = detect_entrypoints(dag) + .entrypoint_nodes + .into_iter() + .map(|node_id| node_id.0) + .collect::>(); + nodes.sort(); + nodes + }, + boundary_nodes: { + let mut nodes = detect_boundaries(dag) + .boundary_nodes + .into_iter() + .map(|node_id| node_id.0) + .collect::>(); + nodes.sort(); + nodes + }, + }; + + let obligation_counts = derive_obligation_counts(&dag.nodes); + let obligations = TestObligations { + dry_run_completion_required: true, + transport_execution_targets: obligation_counts.transport_execution_targets, + pure_node_determinism_targets: obligation_counts.pure_node_determinism_targets, + service_transport_prepare_targets: obligation_counts.service_transport_prepare_targets, + service_transport_execute_targets: obligation_counts.service_transport_execute_targets, + service_transport_parse_targets: obligation_counts.service_transport_parse_targets, + service_param_source_targets: obligation_counts.service_param_source_targets, + resource_provide_targets: obligation_counts.resource_provide_targets, + resource_acquire_targets: obligation_counts.resource_acquire_targets, + resource_release_targets: obligation_counts.resource_release_targets, + }; + + let tool_metadata = ToolMetadata { + modules: derive_module_metadata(&dag.nodes), + }; + + Ok(DerivedArtifacts { + manifest, + obligations, + tool_metadata, + }) +} + +fn compute_waves(dag: &Dag) -> Result>, DeriveError> { + let mut indegree = BTreeMap::::new(); + let mut outgoing = BTreeMap::>::new(); + + for node in &dag.nodes { + indegree.insert(node.id.0.clone(), 0); + outgoing.insert(node.id.0.clone(), Vec::new()); + } + + for edge in &dag.edges { + let to = edge.to_node.0.clone(); + let from = edge.from_node.0.clone(); + let degree = indegree + .get_mut(&to) + .ok_or_else(|| DeriveError::InvalidGraph(format!("edge targets missing node `{to}`")))?; + *degree += 1; + outgoing + .get_mut(&from) + .ok_or_else(|| DeriveError::InvalidGraph(format!("edge source missing node `{from}`")))? + .push(to); + } + + let mut current_wave = indegree + .iter() + .filter_map(|(node, degree)| (*degree == 0).then_some(node.clone())) + .collect::>(); + current_wave.sort(); + + if current_wave.is_empty() { + return Err(DeriveError::InvalidGraph( + "graph has no entrypoint wave (likely cyclic)".to_string(), + )); + } + + let mut waves = Vec::new(); + let mut processed = 0usize; + + while !current_wave.is_empty() { + waves.push(current_wave.clone()); + processed += current_wave.len(); + let mut next_wave = Vec::new(); + + for node in ¤t_wave { + let mut queue = outgoing + .get(node) + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + while let Some(target) = queue.pop_front() { + let degree = indegree.get_mut(&target).ok_or_else(|| { + DeriveError::InvalidGraph(format!("missing indegree for `{target}`")) + })?; + *degree = degree.saturating_sub(1); + if *degree == 0 { + next_wave.push(target); + } + } + } + + next_wave.sort(); + next_wave.dedup(); + current_wave = next_wave; + } + + if processed != dag.nodes.len() { + return Err(DeriveError::InvalidGraph( + "graph contains a cycle and cannot be wave-partitioned".to_string(), + )); + } + + Ok(waves) +} + +fn derive_module_metadata(nodes: &[Node]) -> Vec { + let mut by_module: BTreeMap = BTreeMap::new(); + for node in nodes { + let Some(op) = node.body.as_opaque() else { + continue; + }; + match op { + LoweredOp::Callable { module, .. } => { + let entry = by_module + .entry(module.clone()) + .or_insert_with(|| ModuleMetadata { + module: module.clone(), + callable_count: 0, + pipeline_count: 0, + }); + entry.callable_count += 1; + } + LoweredOp::Pipeline { module, .. } => { + let entry = by_module + .entry(module.clone()) + .or_insert_with(|| ModuleMetadata { + module: module.clone(), + callable_count: 0, + pipeline_count: 0, + }); + entry.pipeline_count += 1; + } + } + } + by_module.into_values().collect() +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ObligationCounts { + transport_execution_targets: usize, + pure_node_determinism_targets: usize, + service_transport_prepare_targets: usize, + service_transport_execute_targets: usize, + service_transport_parse_targets: usize, + service_param_source_targets: usize, + resource_provide_targets: usize, + resource_acquire_targets: usize, + resource_release_targets: usize, +} + +fn derive_obligation_counts(nodes: &[Node]) -> ObligationCounts { + let mut counts = ObligationCounts::default(); + for node in nodes { + if node + .inputs + .iter() + .any(|port| port.type_id.0 == "TransportRequest") + { + counts.transport_execution_targets += 1; + } else { + counts.pure_node_determinism_targets += 1; + } + let Some(LoweredOp::Callable { name, .. }) = node.body.as_opaque() else { + continue; + }; + if name.starts_with("service_transport::prepare::") { + counts.service_transport_prepare_targets += 1; + } else if name.starts_with("service_transport::execute::") { + counts.service_transport_execute_targets += 1; + } else if name.starts_with("service_transport::parse::") { + counts.service_transport_parse_targets += 1; + } else if name.starts_with("call_param_source::") { + counts.service_param_source_targets += 1; + } else if name.starts_with("resource_provide::") { + counts.resource_provide_targets += 1; + } else if name.starts_with("resource_lifecycle::acquire::") { + counts.resource_acquire_targets += 1; + } else if name.starts_with("resource_lifecycle::release::") { + counts.resource_release_targets += 1; + } + } + counts +} + +trait NodeBodyExt { + fn as_opaque(&self) -> Option<&LoweredOp>; +} + +impl NodeBodyExt for gunbc_ir::node::NodeBody { + fn as_opaque(&self) -> Option<&LoweredOp> { + match self { + gunbc_ir::node::NodeBody::Opaque(op) => Some(op), + gunbc_ir::node::NodeBody::SubDag(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use daglang_lower::{CallableKind, LoweredOp}; + use gunbc_ir::{Edge, Node, Port}; + + fn callable_node(id: &str, module: &str, name: &str) -> Node { + Node::opaque( + id, + vec![Port::scalar("input", "String")], + vec![Port::scalar("output", "String")], + LoweredOp::Callable { + module: module.to_string(), + kind: CallableKind::Func, + name: name.to_string(), + }, + ) + } + + #[test] + fn derive_manifest_builds_stable_waves() { + let mut dag = Dag::new(); + dag.add_node(callable_node("a", "tools.makegen", "a")); + dag.add_node(callable_node("b", "tools.makegen", "b")); + dag.add_node(callable_node("c", "tools.makegen", "c")); + dag.add_edge(Edge::new("a", "output", "c", "input")); + dag.add_edge(Edge::new("b", "output", "c", "input")); + + let artifacts = derive_artifacts(&dag).expect("derivation should succeed"); + assert_eq!( + artifacts.manifest.waves, + vec![ + vec!["a".to_string(), "b".to_string()], + vec!["c".to_string()] + ] + ); + assert_eq!(artifacts.manifest.total_nodes, 3); + assert_eq!(artifacts.manifest.total_edges, 2); + } + + #[test] + fn derive_module_metadata_counts_callable_and_pipeline_nodes() { + let mut dag = Dag::new(); + dag.add_node(callable_node("makegen", "tools.makegen", "makegen")); + dag.add_node(Node::opaque( + "ci", + vec![], + vec![Port::scalar("stages", "Int")], + LoweredOp::Pipeline { + module: "pipelines.ci".to_string(), + name: "ci".to_string(), + stages: 12, + }, + )); + + let artifacts = derive_artifacts(&dag).expect("derivation should succeed"); + assert!(artifacts + .tool_metadata + .modules + .iter() + .any(|module| module.module == "tools.makegen" && module.callable_count == 1)); + assert!(artifacts + .tool_metadata + .modules + .iter() + .any(|module| module.module == "pipelines.ci" && module.pipeline_count == 1)); + } + + #[test] + fn derive_obligations_count_transport_and_lifecycle_targets() { + let mut dag = Dag::new(); + dag.add_node(Node::opaque( + "param_source", + vec![], + vec![Port::scalar("path", "String")], + LoweredOp::Callable { + module: "sample.services".to_string(), + kind: CallableKind::Pattern, + name: "call_param_source::run::path".to_string(), + }, + )); + dag.add_node(Node::opaque( + "prepare_transport", + vec![Port::scalar("path", "String")], + vec![Port::scalar("request", "TransportRequest")], + LoweredOp::Callable { + module: "sample.services".to_string(), + kind: CallableKind::Pattern, + name: "service_transport::prepare::FsStorage::read".to_string(), + }, + )); + dag.add_node(Node::opaque( + "execute_transport", + vec![Port::scalar("request", "TransportRequest")], + vec![Port::scalar("response", "TransportResponse")], + LoweredOp::Callable { + module: "sample.services".to_string(), + kind: CallableKind::Pattern, + name: "service_transport::execute::FsStorage::read".to_string(), + }, + )); + dag.add_node(Node::opaque( + "parse_transport", + vec![Port::scalar("response", "TransportResponse")], + vec![Port::scalar("body", "String")], + LoweredOp::Callable { + module: "sample.services".to_string(), + kind: CallableKind::Pattern, + name: "service_transport::parse::FsStorage::read".to_string(), + }, + )); + dag.add_node(Node::opaque( + "provide_resource", + vec![Port::scalar("trigger", "Any")], + vec![Port::scalar("out", "Storage")], + LoweredOp::Callable { + module: "sample.resources".to_string(), + kind: CallableKind::Pattern, + name: "resource_provide::run::out".to_string(), + }, + )); + dag.add_node(Node::opaque( + "acquire_resource", + vec![], + vec![Port::scalar("resource_handle", "ResourceHandle")], + LoweredOp::Callable { + module: "sample.resources".to_string(), + kind: CallableKind::Pattern, + name: "resource_lifecycle::acquire::TempFile".to_string(), + }, + )); + dag.add_node(Node::opaque( + "release_resource", + vec![Port::scalar("resource_handle", "ResourceHandle")], + vec![Port::scalar("released", "Bool")], + LoweredOp::Callable { + module: "sample.resources".to_string(), + kind: CallableKind::Pattern, + name: "resource_lifecycle::release::TempFile".to_string(), + }, + )); + dag.add_edge(Edge::new( + "param_source", + "path", + "prepare_transport", + "path", + )); + dag.add_edge(Edge::new( + "prepare_transport", + "request", + "execute_transport", + "request", + )); + dag.add_edge(Edge::new( + "execute_transport", + "response", + "parse_transport", + "response", + )); + dag.add_edge(Edge::new( + "provide_resource", + "out", + "release_resource", + "resource_handle", + )); + dag.add_edge(Edge::new( + "acquire_resource", + "resource_handle", + "release_resource", + "resource_handle", + )); + + let artifacts = derive_artifacts(&dag).expect("derivation should succeed"); + assert_eq!(artifacts.obligations.transport_execution_targets, 1); + assert_eq!(artifacts.obligations.pure_node_determinism_targets, 6); + assert_eq!(artifacts.obligations.service_transport_prepare_targets, 1); + assert_eq!(artifacts.obligations.service_transport_execute_targets, 1); + assert_eq!(artifacts.obligations.service_transport_parse_targets, 1); + assert_eq!(artifacts.obligations.service_param_source_targets, 1); + assert_eq!(artifacts.obligations.resource_provide_targets, 1); + assert_eq!(artifacts.obligations.resource_acquire_targets, 1); + assert_eq!(artifacts.obligations.resource_release_targets, 1); + } +} diff --git a/core/daglang/daglang-emit/src/lib.rs b/core/daglang/daglang-emit/src/lib.rs index 0d4b9646cd7..1b5e190c387 100644 --- a/core/daglang/daglang-emit/src/lib.rs +++ b/core/daglang/daglang-emit/src/lib.rs @@ -27,6 +27,10 @@ //! └── makefile/ Makefile target (from module metadata) //! ``` +use daglang_derive::{DerivedArtifacts, ProgressManifest}; +use daglang_lower::{CallableKind, LoweredOp}; +use gunbc_ir::Dag; + /// The codegen backend trait. Each target language implements this. pub trait CodegenBackend { /// Emit a type definition (record, enum, alias). @@ -51,9 +55,270 @@ pub trait CodegenBackend { fn emit_progress_manifest(&self, manifest: &str) -> String; } +/// A file emitted by a backend. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmittedFile { + pub path: String, + pub content: String, +} + +/// Backend emission summary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmissionSummary { + pub module_count: usize, + pub callable_count: usize, + pub pipeline_count: usize, +} + +/// Aggregated emission output for a compile request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmissionBundle { + pub backend: String, + pub files: Vec, + pub summary: EmissionSummary, +} + /// Errors during emission. #[derive(Debug)] pub enum EmitError { /// A construct couldn't be emitted for the target backend. UnsupportedConstruct { backend: String, construct: String }, + /// A lowered graph node could not be rendered. + InvalidLoweredNode(String), +} + +impl std::fmt::Display for EmitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsupportedConstruct { backend, construct } => { + write!(f, "backend `{backend}` does not support `{construct}`") + } + Self::InvalidLoweredNode(reason) => { + write!(f, "invalid lowered node encountered during emit: {reason}") + } + } + } +} + +/// Minimal Rust backend used by Phase-1 compiler scaffolding. +#[derive(Debug, Default, Clone, Copy)] +pub struct RustBackend; + +impl CodegenBackend for RustBackend { + fn emit_type(&self, ty: &str) -> String { + format!("// TODO(type): emit `{ty}`") + } + + fn emit_fn(&self, name: &str) -> String { + format!( + "pub fn {name}() {{\n // TODO(fn): compile pure functor body\n}}\n" + ) + } + + fn emit_func(&self, name: &str) -> String { + format!( + "pub fn {name}() {{\n // TODO(func): execute lowered DAG orchestration\n}}\n" + ) + } + + fn emit_transport(&self, spec: &str) -> String { + format!("// TODO(transport): emit transport wiring for `{spec}`") + } + + fn emit_test(&self, obligation: &str) -> String { + format!( + "#[test]\nfn obligation_{}() {{\n // TODO(test): satisfy obligation\n assert!(true);\n}}\n", + sanitize_identifier(obligation) + ) + } + + fn emit_cli(&self, entrypoints: &[String]) -> String { + format!( + "pub fn cli_entrypoints() -> &'static [&'static str] {{\n &{:?}\n}}\n", + entrypoints + ) + } + + fn emit_progress_manifest(&self, manifest: &str) -> String { + format!("// progress-manifest\n{manifest}\n") + } +} + +/// Emit a minimal Rust project bundle from lowered GraphIR and derived artifacts. +pub fn emit_rust_bundle( + dag: &Dag, + artifacts: &DerivedArtifacts, +) -> Result { + let backend = RustBackend; + let mut emitted_functions = Vec::new(); + let mut callable_count = 0usize; + let mut pipeline_count = 0usize; + + for node in &dag.nodes { + let Some(op) = node.body.as_opaque() else { + return Err(EmitError::InvalidLoweredNode(format!( + "subdag node `{}` is not supported in phase-1 emit", + node.id.0 + ))); + }; + + match op { + LoweredOp::Callable { module, kind, name } => { + callable_count += 1; + let fn_name = sanitize_identifier(&format!("{module}_{name}")); + let rendered = match kind { + CallableKind::Fn => backend.emit_fn(&fn_name), + CallableKind::Func | CallableKind::Pattern => backend.emit_func(&fn_name), + }; + emitted_functions.push(rendered); + } + LoweredOp::Pipeline { module, name, .. } => { + pipeline_count += 1; + let fn_name = sanitize_identifier(&format!("{module}_{name}")); + emitted_functions.push(backend.emit_func(&fn_name)); + } + } + } + + let module_count = artifacts.tool_metadata.modules.len(); + let manifest_rendered = render_manifest(&artifacts.manifest); + + let files = vec![ + EmittedFile { + path: "target/generated/rust/main.rs".to_string(), + content: format!( + "// Generated by daglang-emit (phase-1 scaffold)\n\n{}\n{}", + backend.emit_cli( + &artifacts + .manifest + .entrypoint_nodes + .iter() + .map(|entry| sanitize_identifier(entry)) + .collect::>() + ), + emitted_functions.join("\n") + ), + }, + EmittedFile { + path: "target/generated/rust/progress_manifest.txt".to_string(), + content: backend.emit_progress_manifest(&manifest_rendered), + }, + ]; + + Ok(EmissionBundle { + backend: "rust".to_string(), + files, + summary: EmissionSummary { + module_count, + callable_count, + pipeline_count, + }, + }) +} + +fn render_manifest(manifest: &ProgressManifest) -> String { + let mut out = String::new(); + out.push_str(&format!("total_nodes={}\n", manifest.total_nodes)); + out.push_str(&format!("total_edges={}\n", manifest.total_edges)); + out.push_str("waves=\n"); + for (idx, wave) in manifest.waves.iter().enumerate() { + out.push_str(&format!(" [{idx}] {}\n", wave.join(", "))); + } + out.push_str(&format!( + "entrypoint_nodes={}\n", + manifest.entrypoint_nodes.join(", ") + )); + out.push_str(&format!( + "boundary_nodes={}\n", + manifest.boundary_nodes.join(", ") + )); + out +} + +fn sanitize_identifier(value: &str) -> String { + let mut out = value + .chars() + .map(|ch| if ch.is_alphanumeric() { ch } else { '_' }) + .collect::(); + if out.is_empty() { + out.push('_'); + } + if out + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_digit()) + { + out.insert(0, '_'); + } + out +} + +trait NodeBodyExt { + fn as_opaque(&self) -> Option<&LoweredOp>; +} + +impl NodeBodyExt for gunbc_ir::node::NodeBody { + fn as_opaque(&self) -> Option<&LoweredOp> { + match self { + gunbc_ir::node::NodeBody::Opaque(op) => Some(op), + gunbc_ir::node::NodeBody::SubDag(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use daglang_derive::derive_artifacts; + use gunbc_ir::{Edge, Node, Port}; + + fn sample_dag() -> Dag { + let mut dag = Dag::new(); + dag.add_node(Node::opaque( + "tools.makegen::render_makefile", + vec![Port::scalar("registry", "ToolRegistry")], + vec![Port::scalar("return", "String")], + LoweredOp::Callable { + module: "tools.makegen".to_string(), + kind: CallableKind::Fn, + name: "render_makefile".to_string(), + }, + )); + dag.add_node(Node::opaque( + "tools.makegen::makegen", + vec![Port::scalar("registry", "ToolRegistry")], + vec![Port::scalar("written", "Bool")], + LoweredOp::Callable { + module: "tools.makegen".to_string(), + kind: CallableKind::Func, + name: "makegen".to_string(), + }, + )); + dag.add_edge(Edge::new( + "tools.makegen::render_makefile", + "return", + "tools.makegen::makegen", + "registry", + )); + dag + } + + #[test] + fn emit_rust_bundle_generates_main_and_manifest_files() { + let dag = sample_dag(); + let artifacts = derive_artifacts(&dag).expect("derive should succeed"); + let bundle = emit_rust_bundle(&dag, &artifacts).expect("emit should succeed"); + + assert_eq!(bundle.backend, "rust"); + assert_eq!(bundle.files.len(), 2); + assert!(bundle + .files + .iter() + .any(|file| file.path.ends_with("main.rs") && file.content.contains("tools_makegen_makegen"))); + assert!(bundle + .files + .iter() + .any(|file| file.path.ends_with("progress_manifest.txt") && file.content.contains("total_nodes="))); + assert_eq!(bundle.summary.callable_count, 2); + } } diff --git a/core/daglang/daglang-lower/Cargo.toml b/core/daglang/daglang-lower/Cargo.toml index ecb56205d37..c4fd3bce385 100644 --- a/core/daglang/daglang-lower/Cargo.toml +++ b/core/daglang/daglang-lower/Cargo.toml @@ -9,3 +9,6 @@ description = "Lowers typed .dag AST to gunbc GraphIR (Dag/Node/Port/Edge)" daglang-syntax = { path = "../daglang-syntax" } daglang-typecheck = { path = "../daglang-typecheck" } gunbc-ir = { path = "../../ir" } + +[dev-dependencies] +daglang-resolve = { path = "../daglang-resolve" } diff --git a/core/daglang/daglang-lower/src/lib.rs b/core/daglang/daglang-lower/src/lib.rs index 32f55c70712..b02952a674b 100644 --- a/core/daglang/daglang-lower/src/lib.rs +++ b/core/daglang/daglang-lower/src/lib.rs @@ -16,6 +16,114 @@ //! TypedAST → [daglang-lower] → GraphIR (gunbc Dag/Node/Port/Edge) //! ``` +use std::collections::{HashMap, HashSet}; + +use daglang_syntax::ast::{Expr, Item, Stmt, StringPart, TypeExpr}; +use daglang_typecheck::{TypedCallableSignature, TypedItemSignature, TypedProject}; +use gunbc_ir::{diff_topologies, Cardinality, Dag, Edge, Node, Port}; + +/// Lowered operation payload for daglang graph nodes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoweredOp { + Callable { + module: String, + kind: CallableKind, + name: String, + }, + Pipeline { + module: String, + name: String, + stages: usize, + }, +} + +/// Structural parity report between two DAGs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParityReport { + pub candidate_nodes: usize, + pub reference_nodes: usize, + pub candidate_edges: usize, + pub reference_edges: usize, + pub added_nodes: usize, + pub removed_nodes: usize, + pub changed_nodes: usize, + pub added_edges: usize, + pub removed_edges: usize, +} + +impl ParityReport { + pub fn is_exact_match(&self) -> bool { + self.added_nodes == 0 + && self.removed_nodes == 0 + && self.changed_nodes == 0 + && self.added_edges == 0 + && self.removed_edges == 0 + } +} + +/// Kind of lowered callable declaration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CallableKind { + Fn, + Func, + Pattern, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LoweredEndpoint { + node_id: String, + primary_output: String, +} + +#[derive(Debug, Default, Clone)] +struct ServiceEndpointRegistry { + by_key: HashMap>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ServiceTransportEndpoint { + parse: LoweredEndpoint, + prepare_node_id: String, + prepare_inputs: Vec, +} + +#[derive(Debug, Default, Clone)] +struct ResourceLifecycleRegistry { + by_key: HashMap>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResourceLifecycleEndpoint { + acquire_node: Option, + release_node: Option, +} + +#[derive(Debug, Default, Clone)] +struct KnownUsesTypes { + names: HashSet, +} + +impl KnownUsesTypes { + fn insert(&mut self, name: impl Into) { + let canonical = canonical_type_name(name.into().as_str()); + self.names.insert(canonical.clone()); + let short = canonical + .rsplit('.') + .next() + .unwrap_or(canonical.as_str()) + .to_string(); + self.names.insert(short); + } + + fn contains(&self, name: &str) -> bool { + let canonical = canonical_type_name(name); + self.names.contains(&canonical) + || self + .names + .contains(canonical.rsplit('.').next().unwrap_or(canonical.as_str())) + } +} + /// Errors during lowering. #[derive(Debug)] pub enum LowerError { @@ -27,4 +135,2443 @@ pub enum LowerError { InvalidAcquireBlock { resource: String, reason: String }, /// Interface could not be resolved to a concrete resource. UnresolvedInterface { interface: String }, + /// Service call in a callable body could not be resolved to a transport endpoint. + UnresolvedServiceCall { + caller: String, + service_call: String, + }, + /// `uses` clause references an unknown resource/interface lifecycle source. + UnresolvedUsedResource { + caller: String, + binding: String, + resource_type: String, + }, + /// `provides` clause references an unknown resource/interface source. + UnresolvedProvidedResource { + caller: String, + binding: String, + resource_type: String, + }, + /// No executable declarations were available for lowering. + NoLowerableItems, +} + +impl std::fmt::Display for LowerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnknownPattern(name) => write!(f, "unknown pattern `{name}`"), + Self::MissingTransport { service, operation } => { + write!( + f, + "service `{service}` operation `{operation}` has no transport annotation" + ) + } + Self::InvalidAcquireBlock { resource, reason } => { + write!(f, "invalid acquire block for `{resource}`: {reason}") + } + Self::UnresolvedInterface { interface } => { + write!(f, "unresolved interface `{interface}`") + } + Self::UnresolvedServiceCall { + caller, + service_call, + } => write!( + f, + "unresolved service call `{service_call}` in `{caller}`" + ), + Self::UnresolvedUsedResource { + caller, + binding, + resource_type, + } => write!( + f, + "unresolved used resource `{binding}: {resource_type}` in `{caller}`" + ), + Self::UnresolvedProvidedResource { + caller, + binding, + resource_type, + } => write!( + f, + "unresolved provided resource `{binding}: {resource_type}` in `{caller}`" + ), + Self::NoLowerableItems => write!(f, "no callable or pipeline declarations to lower"), + } + } +} + +/// Lower a typed project into a structural GraphIR DAG. +/// +/// Phase-1 lowering focuses on callable and pipeline signatures: +/// - `fn` / `func` / `pattern` become opaque callable nodes +/// - `pipeline` declarations become opaque pipeline nodes +/// - type/service/resource/interface declarations remain metadata and are not +/// lowered into executable graph nodes yet. +pub fn lower_typed_project(project: &TypedProject) -> Result, LowerError> { + let mut dag = Dag::new(); + let mut endpoints_by_full = HashMap::<(String, String), LoweredEndpoint>::new(); + let mut endpoints_by_name = HashMap::>::new(); + + for module in &project.modules { + let module_name = module.module_path.join("."); + for signature in &module.signatures { + match signature { + TypedItemSignature::Fn(callable) => { + let (node, endpoint) = lower_callable(callable, &module_name, CallableKind::Fn); + register_endpoint( + &mut endpoints_by_full, + &mut endpoints_by_name, + &module_name, + &callable.name, + endpoint, + ); + dag.add_node(node); + } + TypedItemSignature::Func(callable) => { + let (node, endpoint) = + lower_callable(callable, &module_name, CallableKind::Func); + register_endpoint( + &mut endpoints_by_full, + &mut endpoints_by_name, + &module_name, + &callable.name, + endpoint, + ); + dag.add_node(node); + } + TypedItemSignature::Pattern(callable) => { + let (node, endpoint) = + lower_callable(callable, &module_name, CallableKind::Pattern); + register_endpoint( + &mut endpoints_by_full, + &mut endpoints_by_name, + &module_name, + &callable.name, + endpoint, + ); + dag.add_node(node); + } + TypedItemSignature::Pipeline { name, stages } => { + let node_id = lowered_node_id(&module_name, name); + dag.add_node(Node::opaque( + node_id, + vec![], + vec![Port::with_cardinality( + "stages", + "Int", + Cardinality::ONE, + )], + LoweredOp::Pipeline { + module: module_name.clone(), + name: name.clone(), + stages: *stages, + }, + )); + } + TypedItemSignature::Type { .. } + | TypedItemSignature::Service { .. } + | TypedItemSignature::Resource { .. } + | TypedItemSignature::Interface { .. } => {} + } + } + } + + add_dependency_edges(&mut dag, project, &endpoints_by_full, &endpoints_by_name); + add_makegen_scaffolding(&mut dag, &endpoints_by_full); + let service_registry = add_service_transport_triplets(&mut dag, project); + add_service_call_edges(&mut dag, project, &endpoints_by_full, &service_registry)?; + let resource_registry = add_resource_lifecycle_nodes(&mut dag, project); + let known_uses_types = collect_known_uses_types(project); + add_used_resource_edges( + &mut dag, + project, + &endpoints_by_full, + &resource_registry, + &known_uses_types, + )?; + add_provided_resource_nodes( + &mut dag, + project, + &endpoints_by_full, + &resource_registry, + &known_uses_types, + )?; + + if dag.nodes.is_empty() { + return Err(LowerError::NoLowerableItems); + } + + Ok(dag) +} + +/// Compare a lowered daglang graph against a reference graph topology. +/// +/// This enables incremental parity harness adoption: +/// - exact parity: `report.is_exact_match() == true` +/// - scaffold mode: report still gives deterministic deltas while lowering +/// coverage grows. +pub fn compare_topology(candidate: &Dag, reference: &Dag) -> ParityReport { + let candidate_topology = candidate.topology(); + let reference_topology = reference.topology(); + let diff = diff_topologies(&reference_topology, &candidate_topology); + + ParityReport { + candidate_nodes: candidate_topology.nodes.len(), + reference_nodes: reference_topology.nodes.len(), + candidate_edges: candidate_topology.edges.len(), + reference_edges: reference_topology.edges.len(), + added_nodes: diff.added_nodes.len(), + removed_nodes: diff.removed_nodes.len(), + changed_nodes: diff.changed_nodes.len(), + added_edges: diff.added_edges.len(), + removed_edges: diff.removed_edges.len(), + } +} + +/// Compare makegen topology with normalization rules for known scaffold deltas. +/// +/// Normalization currently: +/// - strips `tools.makegen::` node-id prefixes +/// - drops the wrapper `makegen` callable node +/// - removes synthetic `__deps` ports/edges +/// - canonicalizes `render_makefile.return` output to `makefile_content` +/// - ignores environment-scaffold edge `fs_env -> prepare_read_makegen` +pub fn compare_makegen_topology(candidate: &Dag, reference: &Dag) -> ParityReport { + let normalized = normalize_makegen_candidate(candidate); + compare_topology(&normalized, reference) +} + +fn normalize_makegen_candidate(candidate: &Dag) -> Dag { + let mut normalized = Dag::new(); + let mut kept_nodes = HashSet::::new(); + + for node in &candidate.nodes { + let Some(op) = node_body_as_opaque(&node.body).cloned() else { + continue; + }; + let canonical_id = canonical_makegen_node_id(&node.id.0); + if canonical_id == "makegen" { + continue; + } + + let mut inputs = node + .inputs + .iter() + .filter(|port| port.name.0 != "__deps") + .cloned() + .collect::>(); + inputs.sort_by(|lhs, rhs| lhs.name.0.cmp(&rhs.name.0)); + + let mut outputs = node.outputs.clone(); + if canonical_id == "render_makefile" { + for output in &mut outputs { + if output.name.0 == "return" { + output.name.0 = "makefile_content".to_string(); + } + } + } + outputs.sort_by(|lhs, rhs| lhs.name.0.cmp(&rhs.name.0)); + + normalized.add_node(Node::opaque(canonical_id.clone(), inputs, outputs, op)); + kept_nodes.insert(canonical_id); + } + + let mut seen_edges = HashSet::<(String, String, String, String)>::new(); + for edge in &candidate.edges { + let from_node = canonical_makegen_node_id(&edge.from_node.0); + let to_node = canonical_makegen_node_id(&edge.to_node.0); + if from_node == "makegen" || to_node == "makegen" { + continue; + } + if !kept_nodes.contains(&from_node) || !kept_nodes.contains(&to_node) { + continue; + } + if edge.from_port.0 == "__deps" || edge.to_port.0 == "__deps" { + continue; + } + if from_node == "fs_env" + && to_node == "prepare_read_makegen" + && edge.to_port.0 == "res:file:Makefile" + { + continue; + } + + let from_port = if from_node == "render_makefile" && edge.from_port.0 == "return" { + "makefile_content".to_string() + } else { + edge.from_port.0.clone() + }; + let key = ( + from_node, + from_port, + to_node, + edge.to_port.0.clone(), + ); + if seen_edges.insert(key.clone()) { + normalized.add_edge(Edge::new(key.0, key.1, key.2, key.3)); + } + } + + normalized +} + +fn canonical_makegen_node_id(node_id: &str) -> String { + node_id + .strip_prefix("tools.makegen::") + .unwrap_or(node_id) + .to_string() +} + +fn node_body_as_opaque(body: &gunbc_ir::node::NodeBody) -> Option<&LoweredOp> { + match body { + gunbc_ir::node::NodeBody::Opaque(op) => Some(op), + gunbc_ir::node::NodeBody::SubDag(_) => None, + } +} + +fn lower_callable( + callable: &TypedCallableSignature, + module_name: &str, + kind: CallableKind, +) -> (Node, LoweredEndpoint) { + let node_id = lowered_node_id(module_name, &callable.name); + let mut inputs = callable + .params + .iter() + .map(|binding| { + Port::with_cardinality(binding.name.as_str(), binding.ty.as_str(), Cardinality::ONE) + }) + .collect::>(); + inputs.push(Port::with_cardinality( + "__deps", + "Any", + Cardinality::ZERO_OR_MORE, + )); + let outputs = if callable.outputs.is_empty() { + vec![Port::with_cardinality("return", "Unit", Cardinality::ONE)] + } else { + callable + .outputs + .iter() + .map(|binding| { + Port::with_cardinality( + binding.name.as_str(), + binding.ty.as_str(), + Cardinality::ONE, + ) + }) + .collect() + }; + let primary_output = outputs + .first() + .map(|port| port.name.0.clone()) + .unwrap_or_else(|| "return".to_string()); + ( + Node::opaque( + node_id.clone(), + inputs, + outputs, + LoweredOp::Callable { + module: module_name.to_string(), + kind, + name: callable.name.clone(), + }, + ), + LoweredEndpoint { + node_id, + primary_output, + }, + ) +} + +fn lowered_node_id(module_name: &str, item_name: &str) -> String { + format!("{module_name}::{item_name}").replace([' ', '/'], "_") +} + +fn register_endpoint( + by_full: &mut HashMap<(String, String), LoweredEndpoint>, + by_name: &mut HashMap>, + module_name: &str, + callable_name: &str, + endpoint: LoweredEndpoint, +) { + by_full.insert( + (module_name.to_string(), callable_name.to_string()), + endpoint.clone(), + ); + by_name + .entry(callable_name.to_string()) + .and_modify(|existing| { + if let Some(current) = existing { + if current != &endpoint { + *existing = None; + } + } + }) + .or_insert(Some(endpoint)); +} + +fn add_dependency_edges( + dag: &mut Dag, + project: &TypedProject, + endpoints_by_full: &HashMap<(String, String), LoweredEndpoint>, + endpoints_by_name: &HashMap>, +) { + let mut seen_edges = HashSet::<(String, String, String, String)>::new(); + let mut seen_nodes = dag + .nodes + .iter() + .map(|node| node.id.0.clone()) + .collect::>(); + + for module in &project.modules { + let module_name = module.module_path.join("."); + for item in &module.ast.items { + let Some((item_name, stmts)) = item_callable_body(&item.node) else { + continue; + }; + let Some(target) = + endpoints_by_full.get(&(module_name.clone(), item_name.to_string())) + else { + continue; + }; + + let mut calls = HashSet::new(); + collect_calls_from_stmts(stmts, &mut calls); + for call in calls { + let Some(Some(source)) = endpoints_by_name.get(&call) else { + continue; + }; + if source.node_id == target.node_id { + continue; + } + let key = edge_key( + source.node_id.clone(), + source.primary_output.clone(), + target.node_id.clone(), + "__deps".to_string(), + ); + add_edge_if_new(dag, &mut seen_edges, key); + } + + expand_content_upsert_patterns( + dag, + &module_name, + item_name, + stmts, + target, + endpoints_by_name, + &mut seen_nodes, + &mut seen_edges, + ); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn expand_content_upsert_patterns( + dag: &mut Dag, + module_name: &str, + item_name: &str, + stmts: &[Stmt], + target: &LoweredEndpoint, + endpoints_by_name: &HashMap>, + seen_nodes: &mut HashSet, + seen_edges: &mut HashSet<(String, String, String, String)>, +) { + let mut bound_callables = HashMap::::new(); + let mut expansion_count = 0usize; + + for stmt in stmts { + let maybe_binding = match stmt { + Stmt::Let(name, expr) | Stmt::Assign(name, expr) => Some((name, expr)), + Stmt::Expr(expr) => { + if let Expr::Call(name, args) = expr { + if name == "content_upsert" { + expansion_count += 1; + expand_single_content_upsert( + dag, + module_name, + item_name, + expansion_count, + args, + target, + &bound_callables, + endpoints_by_name, + seen_nodes, + seen_edges, + ); + } + } + None + } + Stmt::Return(_) => None, + }; + + let Some((binding, expr)) = maybe_binding else { + continue; + }; + match expr { + Expr::Call(name, args) => { + if should_track_call(name) { + bound_callables.insert(binding.clone(), name.clone()); + } + if name == "content_upsert" { + expansion_count += 1; + expand_single_content_upsert( + dag, + module_name, + item_name, + expansion_count, + args, + target, + &bound_callables, + endpoints_by_name, + seen_nodes, + seen_edges, + ); + } + } + Expr::Ident(source) => { + if let Some(origin) = bound_callables.get(source) { + bound_callables.insert(binding.clone(), origin.clone()); + } + } + _ => {} + } + } +} + +#[allow(clippy::too_many_arguments)] +fn expand_single_content_upsert( + dag: &mut Dag, + module_name: &str, + item_name: &str, + expansion_count: usize, + args: &[(Option, Expr)], + target: &LoweredEndpoint, + bound_callables: &HashMap, + endpoints_by_name: &HashMap>, + seen_nodes: &mut HashSet, + seen_edges: &mut HashSet<(String, String, String, String)>, +) { + let suffix = expansion_suffix(item_name, expansion_count); + let prepare_read_id = format!("prepare_read_{suffix}"); + let execute_read_id = format!("execute_read_{suffix}"); + let compare_id = format!("compare_{suffix}_content"); + let prepare_write_id = format!("prepare_write_{suffix}"); + let execute_transport_id = format!("execute_{suffix}_transport"); + + add_node_if_missing( + dag, + seen_nodes, + Node::opaque( + prepare_read_id.clone(), + vec![ + Port::scalar("path", "String"), + Port::scalar("res:file:Makefile", "FilesystemHandle"), + ], + vec![Port::scalar("request", "TransportRequest")], + LoweredOp::Callable { + module: module_name.to_string(), + kind: CallableKind::Pattern, + name: format!("content_upsert::{prepare_read_id}"), + }, + ), + ); + add_node_if_missing( + dag, + seen_nodes, + Node::opaque( + execute_read_id.clone(), + vec![Port::scalar("request", "TransportRequest")], + vec![Port::scalar("response", "TransportResponse")], + LoweredOp::Callable { + module: module_name.to_string(), + kind: CallableKind::Pattern, + name: format!("content_upsert::{execute_read_id}"), + }, + ), + ); + add_node_if_missing( + dag, + seen_nodes, + Node::opaque( + compare_id.clone(), + vec![ + Port::scalar("expected_content", "String"), + Port::scalar("response", "TransportResponse"), + ], + vec![ + Port::scalar("fresh", "Bool"), + Port::scalar("skip", "Bool"), + ], + LoweredOp::Callable { + module: module_name.to_string(), + kind: CallableKind::Pattern, + name: format!("content_upsert::{compare_id}"), + }, + ), + ); + add_node_if_missing( + dag, + seen_nodes, + Node::opaque( + prepare_write_id.clone(), + vec![ + Port::scalar("content", "String"), + Port::scalar("path", "String"), + ], + vec![Port::scalar("request", "TransportRequest")], + LoweredOp::Callable { + module: module_name.to_string(), + kind: CallableKind::Pattern, + name: format!("content_upsert::{prepare_write_id}"), + }, + ), + ); + add_node_if_missing( + dag, + seen_nodes, + Node::opaque( + execute_transport_id.clone(), + vec![ + Port::scalar("request", "TransportRequest"), + Port::scalar("skip", "Bool"), + ], + vec![Port::scalar("makegen_response", "TransportResponse")], + LoweredOp::Callable { + module: module_name.to_string(), + kind: CallableKind::Pattern, + name: format!("content_upsert::{execute_transport_id}"), + }, + ), + ); + + add_edge_if_new( + dag, + seen_edges, + edge_key( + prepare_read_id.clone(), + "request".to_string(), + execute_read_id.clone(), + "request".to_string(), + ), + ); + add_edge_if_new( + dag, + seen_edges, + edge_key( + execute_read_id.clone(), + "response".to_string(), + compare_id.clone(), + "response".to_string(), + ), + ); + add_edge_if_new( + dag, + seen_edges, + edge_key( + prepare_write_id.clone(), + "request".to_string(), + execute_transport_id.clone(), + "request".to_string(), + ), + ); + add_edge_if_new( + dag, + seen_edges, + edge_key( + compare_id.clone(), + "skip".to_string(), + execute_transport_id.clone(), + "skip".to_string(), + ), + ); + add_edge_if_new( + dag, + seen_edges, + edge_key( + execute_transport_id, + "makegen_response".to_string(), + target.node_id.clone(), + "__deps".to_string(), + ), + ); + + if let Some(source) = resolve_content_source(args, bound_callables, endpoints_by_name) { + add_edge_if_new( + dag, + seen_edges, + edge_key( + source.node_id.clone(), + source.primary_output.clone(), + compare_id.clone(), + "expected_content".to_string(), + ), + ); + add_edge_if_new( + dag, + seen_edges, + edge_key( + source.node_id, + source.primary_output, + prepare_write_id, + "content".to_string(), + ), + ); + } +} + +fn resolve_content_source( + args: &[(Option, Expr)], + bound_callables: &HashMap, + endpoints_by_name: &HashMap>, +) -> Option { + let (_, content_expr) = args + .iter() + .find(|(name, _)| matches!(name.as_deref(), Some("content")))?; + let source_name = match content_expr { + Expr::Ident(name) => bound_callables + .get(name) + .cloned() + .unwrap_or_else(|| name.clone()), + Expr::Call(name, _) => name.clone(), + _ => return None, + }; + endpoints_by_name.get(&source_name).and_then(|entry| entry.clone()) +} + +fn add_node_if_missing( + dag: &mut Dag, + seen_nodes: &mut HashSet, + node: Node, +) { + let node_id = node.id.0.clone(); + if seen_nodes.insert(node_id) { + dag.add_node(node); + } +} + +fn add_edge_if_new( + dag: &mut Dag, + seen_edges: &mut HashSet<(String, String, String, String)>, + key: (String, String, String, String), +) { + if seen_edges.insert(key.clone()) { + dag.add_edge(Edge::new(key.0, key.1, key.2, key.3)); + } +} + +fn edge_key( + from_node: String, + from_port: String, + to_node: String, + to_port: String, +) -> (String, String, String, String) { + (from_node, from_port, to_node, to_port) +} + +fn expansion_suffix(item_name: &str, expansion_count: usize) -> String { + let base = item_name + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect::(); + if expansion_count <= 1 { + base + } else { + format!("{base}_{expansion_count}") + } +} + +fn sanitize_identifier(value: &str) -> String { + let mut out = value + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect::(); + if out.is_empty() { + out.push('_'); + } + out +} + +fn type_expr_to_string(expr: &TypeExpr) -> String { + match expr { + TypeExpr::Named(name) => name.clone(), + TypeExpr::Generic(name, args) => format!( + "{name}<{}>", + args.iter() + .map(type_expr_to_string) + .collect::>() + .join(", ") + ), + TypeExpr::Optional(inner) => format!("{}?", type_expr_to_string(inner)), + TypeExpr::Annotated(inner, annotations) => format!( + "{} {}", + type_expr_to_string(inner), + annotations + .iter() + .map(|annotation| format!("@{}", annotation.name)) + .collect::>() + .join(" ") + ), + } +} + +fn add_makegen_scaffolding( + dag: &mut Dag, + endpoints_by_full: &HashMap<(String, String), LoweredEndpoint>, +) { + let Some(render) = endpoints_by_full + .get(&("tools.makegen".to_string(), "render_makefile".to_string())) + else { + return; + }; + let makegen = endpoints_by_full.get(&("tools.makegen".to_string(), "makegen".to_string())); + + if !dag.nodes.iter().any(|node| node.id.0 == "load_registry") { + dag.add_node(Node::opaque( + "load_registry", + vec![], + vec![Port::scalar("registry", "ToolRegistry")], + LoweredOp::Callable { + module: "tools.makegen".to_string(), + kind: CallableKind::Pattern, + name: "load_registry".to_string(), + }, + )); + } + add_edge_once( + dag, + "load_registry", + "registry", + render.node_id.as_str(), + "registry", + ); + if let Some(makegen_endpoint) = makegen { + add_edge_once( + dag, + "load_registry", + "registry", + makegen_endpoint.node_id.as_str(), + "registry", + ); + } + + if dag + .nodes + .iter() + .any(|node| node.id.0 == "prepare_read_makegen") + { + if !dag.nodes.iter().any(|node| node.id.0 == "fs_env") { + dag.add_node(Node::opaque( + "fs_env", + vec![], + vec![Port::scalar("FilesystemHandle", "FilesystemHandle")], + LoweredOp::Callable { + module: "tools.makegen".to_string(), + kind: CallableKind::Pattern, + name: "fs_env".to_string(), + }, + )); + } + add_edge_once( + dag, + "fs_env", + "FilesystemHandle", + "prepare_read_makegen", + "res:file:Makefile", + ); + } +} + +fn add_service_transport_triplets( + dag: &mut Dag, + project: &TypedProject, +) -> ServiceEndpointRegistry { + let mut registry = ServiceEndpointRegistry::default(); + for module in &project.modules { + let module_name = module.module_path.join("."); + for item in &module.ast.items { + let Item::ServiceDef(service) = &item.node else { + continue; + }; + if service.implements.is_none() { + continue; + } + + for operation in &service.operations { + let suffix = sanitize_identifier(&format!( + "{module_name}_{}_{}", + service.name, operation.name + )); + let prepare_id = format!("prepare_transport_{suffix}"); + let execute_id = format!("execute_transport_{suffix}"); + let parse_id = format!("parse_transport_{suffix}"); + + add_node_once( + dag, + Node::opaque( + prepare_id.clone(), + operation + .inputs + .iter() + .map(|field| { + let ty = type_expr_to_string(&field.ty); + Port::with_cardinality( + field.name.as_str(), + ty.as_str(), + Cardinality::ONE, + ) + }) + .collect::>(), + vec![Port::scalar("request", "TransportRequest")], + LoweredOp::Callable { + module: module_name.clone(), + kind: CallableKind::Pattern, + name: format!( + "service_transport::prepare::{}::{}", + service.name, operation.name + ), + }, + ), + ); + add_node_once( + dag, + Node::opaque( + execute_id.clone(), + vec![Port::scalar("request", "TransportRequest")], + vec![Port::scalar("response", "TransportResponse")], + LoweredOp::Callable { + module: module_name.clone(), + kind: CallableKind::Pattern, + name: format!( + "service_transport::execute::{}::{}", + service.name, operation.name + ), + }, + ), + ); + let parse_outputs = if operation.outputs.is_empty() { + vec![Port::scalar("result", "Unit")] + } else { + operation + .outputs + .iter() + .map(|field| { + let ty = type_expr_to_string(&field.ty); + Port::with_cardinality( + field.name.as_str(), + ty.as_str(), + Cardinality::ONE, + ) + }) + .collect::>() + }; + add_node_once( + dag, + Node::opaque( + parse_id.clone(), + vec![Port::scalar("response", "TransportResponse")], + parse_outputs, + LoweredOp::Callable { + module: module_name.clone(), + kind: CallableKind::Pattern, + name: format!( + "service_transport::parse::{}::{}", + service.name, operation.name + ), + }, + ), + ); + + add_edge_once( + dag, + prepare_id.as_str(), + "request", + execute_id.as_str(), + "request", + ); + add_edge_once( + dag, + execute_id.as_str(), + "response", + parse_id.as_str(), + "response", + ); + + let parse_output = operation + .outputs + .first() + .map(|field| field.name.clone()) + .unwrap_or_else(|| "result".to_string()); + let endpoint = ServiceTransportEndpoint { + parse: LoweredEndpoint { + node_id: parse_id, + primary_output: parse_output, + }, + prepare_node_id: prepare_id, + prepare_inputs: operation + .inputs + .iter() + .map(|field| field.name.clone()) + .collect::>(), + }; + register_service_endpoint( + &mut registry, + format!("{}.{}", service.name, operation.name), + endpoint.clone(), + ); + let service_tail = service + .name + .rsplit('.') + .next() + .unwrap_or(service.name.as_str()); + register_service_endpoint( + &mut registry, + format!("{service_tail}.{}", operation.name), + endpoint.clone(), + ); + register_service_endpoint( + &mut registry, + format!("{}.{}.{}", module_name, service.name, operation.name), + endpoint, + ); + } + } + } + registry +} + +fn add_node_once(dag: &mut Dag, node: Node) { + if dag.nodes.iter().any(|existing| existing.id.0 == node.id.0) { + return; + } + dag.add_node(node); +} + +fn register_service_endpoint( + registry: &mut ServiceEndpointRegistry, + key: String, + endpoint: ServiceTransportEndpoint, +) { + registry + .by_key + .entry(key) + .and_modify(|existing| { + if let Some(current) = existing { + if current != &endpoint { + *existing = None; + } + } + }) + .or_insert(Some(endpoint)); +} + +fn register_resource_endpoint( + registry: &mut ResourceLifecycleRegistry, + key: String, + endpoint: ResourceLifecycleEndpoint, +) { + registry + .by_key + .entry(key) + .and_modify(|existing| { + if let Some(current) = existing { + if current != &endpoint { + *existing = None; + } + } + }) + .or_insert(Some(endpoint)); +} + +fn add_service_call_edges( + dag: &mut Dag, + project: &TypedProject, + endpoints_by_full: &HashMap<(String, String), LoweredEndpoint>, + service_registry: &ServiceEndpointRegistry, +) -> Result<(), LowerError> { + for module in &project.modules { + let module_name = module.module_path.join("."); + for item in &module.ast.items { + let (item_name, params, stmts, uses_bindings) = match &item.node { + Item::FnDef(def) => ( + &def.name, + &def.params, + def.body.stmts.as_slice(), + HashSet::new(), + ), + Item::FuncDef(def) => ( + &def.name, + &def.params, + def.body.stmts.as_slice(), + def.uses + .iter() + .map(|usage| usage.binding.clone()) + .collect::>(), + ), + Item::PatternDef(def) => ( + &def.name, + &def.params, + def.body.stmts.as_slice(), + def.uses + .iter() + .map(|usage| usage.binding.clone()) + .collect::>(), + ), + _ => continue, + }; + let Some(target) = + endpoints_by_full.get(&(module_name.clone(), item_name.to_string())) + else { + continue; + }; + let param_types = params + .iter() + .map(|param| (param.name.clone(), type_expr_to_string(¶m.ty))) + .collect::>(); + let mut service_calls = Vec::::new(); + collect_service_calls_from_stmts(stmts, &mut service_calls); + for call in service_calls { + let Some(source) = resolve_service_endpoint(&call.path, service_registry) else { + if call + .path + .first() + .is_some_and(|segment| uses_bindings.contains(segment)) + { + continue; + } + return Err(LowerError::UnresolvedServiceCall { + caller: format!("{module_name}::{item_name}"), + service_call: call.path.join("."), + }); + }; + add_edge_once( + dag, + source.parse.node_id.as_str(), + source.parse.primary_output.as_str(), + target.node_id.as_str(), + "__deps", + ); + for (index, arg) in call.args.iter().enumerate() { + let Some(arg_ident) = arg.ident.as_deref() else { + continue; + }; + let Some(param_ty) = param_types.get(arg_ident) else { + continue; + }; + let Some(prepare_input) = arg + .name + .as_deref() + .or_else(|| source.prepare_inputs.get(index).map(String::as_str)) + else { + continue; + }; + let param_source = ensure_param_source_node( + dag, + module_name.as_str(), + item_name, + arg_ident, + param_ty.as_str(), + ); + add_edge_once( + dag, + param_source.as_str(), + arg_ident, + source.prepare_node_id.as_str(), + prepare_input, + ); + } + } + } + } + Ok(()) +} + +fn add_used_resource_edges( + dag: &mut Dag, + project: &TypedProject, + endpoints_by_full: &HashMap<(String, String), LoweredEndpoint>, + resource_registry: &ResourceLifecycleRegistry, + known_uses_types: &KnownUsesTypes, +) -> Result<(), LowerError> { + for module in &project.modules { + let module_name = module.module_path.join("."); + for item in &module.ast.items { + let Some((item_name, uses)) = item_callable_uses(&item.node) else { + continue; + }; + let Some(target) = + endpoints_by_full.get(&(module_name.clone(), item_name.to_string())) + else { + continue; + }; + for usage in uses { + let resource_type = resource_type_name(&usage.resource_type); + let Some(endpoint) = resolve_resource_endpoint( + module_name.as_str(), + resource_type.as_str(), + resource_registry, + ) else { + if known_uses_types.contains(&resource_type) { + continue; + } + return Err(LowerError::UnresolvedUsedResource { + caller: format!("{module_name}::{item_name}"), + binding: usage.binding.clone(), + resource_type, + }); + }; + if let Some(acquire_node) = endpoint.acquire_node { + add_edge_once( + dag, + acquire_node.as_str(), + "resource_handle", + target.node_id.as_str(), + "__deps", + ); + } + if let Some(release_node) = endpoint.release_node { + add_edge_once( + dag, + target.node_id.as_str(), + target.primary_output.as_str(), + release_node.as_str(), + "resource_handle", + ); + } + } + } + } + Ok(()) +} + +fn add_provided_resource_nodes( + dag: &mut Dag, + project: &TypedProject, + endpoints_by_full: &HashMap<(String, String), LoweredEndpoint>, + resource_registry: &ResourceLifecycleRegistry, + known_uses_types: &KnownUsesTypes, +) -> Result<(), LowerError> { + for module in &project.modules { + let module_name = module.module_path.join("."); + for item in &module.ast.items { + let Some((item_name, provides)) = item_callable_provides(&item.node) else { + continue; + }; + let Some(target) = + endpoints_by_full.get(&(module_name.clone(), item_name.to_string())) + else { + continue; + }; + for provided in provides { + let resource_type = resource_type_name(&provided.resource_type); + let endpoint = resolve_resource_endpoint( + module_name.as_str(), + resource_type.as_str(), + resource_registry, + ); + if endpoint.is_none() && !known_uses_types.contains(&resource_type) { + return Err(LowerError::UnresolvedProvidedResource { + caller: format!("{module_name}::{item_name}"), + binding: provided.binding.clone(), + resource_type, + }); + } + + let provider_node_id = format!( + "provide_resource_{}", + sanitize_identifier(&format!( + "{module_name}_{item_name}_{}", + provided.binding + )) + ); + add_node_once( + dag, + Node::opaque( + provider_node_id.clone(), + vec![Port::scalar("trigger", "Any")], + vec![Port::with_cardinality( + provided.binding.as_str(), + resource_type.as_str(), + Cardinality::ONE, + )], + LoweredOp::Callable { + module: module_name.clone(), + kind: CallableKind::Pattern, + name: format!( + "resource_provide::{}::{}", + item_name, provided.binding + ), + }, + ), + ); + add_edge_once( + dag, + target.node_id.as_str(), + target.primary_output.as_str(), + provider_node_id.as_str(), + "trigger", + ); + if let Some(endpoint) = endpoint { + if let Some(release_node) = endpoint.release_node { + add_edge_once( + dag, + provider_node_id.as_str(), + provided.binding.as_str(), + release_node.as_str(), + "resource_handle", + ); + } + } + } + } + } + Ok(()) +} + +fn resolve_resource_endpoint( + module_name: &str, + resource_type: &str, + registry: &ResourceLifecycleRegistry, +) -> Option { + let keys = [ + format!("{module_name}.{resource_type}"), + resource_type.to_string(), + ]; + for key in keys { + if let Some(Some(endpoint)) = registry.by_key.get(&key) { + return Some(endpoint.clone()); + } + } + None +} + +fn collect_known_uses_types(project: &TypedProject) -> KnownUsesTypes { + let mut known = KnownUsesTypes::default(); + for module in &project.modules { + let module_name = module.module_path.join("."); + for item in &module.ast.items { + match &item.node { + Item::InterfaceDef(def) => { + known.insert(def.name.clone()); + known.insert(format!("{module_name}.{}", def.name)); + } + Item::ResourceDef(def) => { + known.insert(def.name.clone()); + known.insert(format!("{module_name}.{}", def.name)); + if let Some(implemented) = &def.implements { + known.insert(implemented.clone()); + } + } + Item::ServiceDef(def) => { + known.insert(def.name.clone()); + known.insert(format!("{module_name}.{}", def.name)); + if let Some(implemented) = &def.implements { + known.insert(implemented.clone()); + } + } + _ => {} + } + } + } + known +} + +fn resource_type_name(ty: &TypeExpr) -> String { + match ty { + TypeExpr::Named(name) | TypeExpr::Generic(name, _) => canonical_type_name(name), + TypeExpr::Optional(inner) | TypeExpr::Annotated(inner, _) => resource_type_name(inner), + } +} + +fn canonical_type_name(name: &str) -> String { + let base_without_config = name.split('(').next().unwrap_or(name).trim(); + let base_without_annotations = base_without_config + .split_whitespace() + .next() + .unwrap_or(base_without_config); + base_without_annotations + .split('<') + .next() + .unwrap_or(base_without_annotations) + .trim() + .to_string() +} + +fn add_resource_lifecycle_nodes( + dag: &mut Dag, + project: &TypedProject, +) -> ResourceLifecycleRegistry { + let mut registry = ResourceLifecycleRegistry::default(); + for module in &project.modules { + let module_name = module.module_path.join("."); + for item in &module.ast.items { + let Item::ResourceDef(resource) = &item.node else { + continue; + }; + let suffix = sanitize_identifier(&format!("{module_name}_{}", resource.name)); + let acquire_id = format!("acquire_resource_{suffix}"); + let release_id = format!("release_resource_{suffix}"); + let mut has_acquire = false; + let mut has_release = false; + + if resource.acquire.is_some() { + add_node_once( + dag, + Node::opaque( + acquire_id.clone(), + vec![], + vec![Port::scalar("resource_handle", "ResourceHandle")], + LoweredOp::Callable { + module: module_name.clone(), + kind: CallableKind::Pattern, + name: format!("resource_lifecycle::acquire::{}", resource.name), + }, + ), + ); + has_acquire = true; + } + if resource.release.is_some() { + add_node_once( + dag, + Node::opaque( + release_id.clone(), + vec![Port::scalar("resource_handle", "ResourceHandle")], + vec![Port::scalar("released", "Bool")], + LoweredOp::Callable { + module: module_name.clone(), + kind: CallableKind::Pattern, + name: format!("resource_lifecycle::release::{}", resource.name), + }, + ), + ); + has_release = true; + } + if has_acquire && has_release { + add_edge_once( + dag, + acquire_id.as_str(), + "resource_handle", + release_id.as_str(), + "resource_handle", + ); + } + let endpoint = ResourceLifecycleEndpoint { + acquire_node: has_acquire.then_some(acquire_id), + release_node: has_release.then_some(release_id), + }; + register_resource_endpoint( + &mut registry, + format!("{module_name}.{}", resource.name), + endpoint.clone(), + ); + register_resource_endpoint(&mut registry, resource.name.clone(), endpoint); + } + } + registry +} + +fn resolve_service_endpoint( + call_path: &[String], + registry: &ServiceEndpointRegistry, +) -> Option { + if call_path.len() < 2 { + return None; + } + let operation = call_path.last()?; + let service_name = call_path[..call_path.len() - 1].join("."); + let short_service = call_path[call_path.len() - 2].clone(); + let keys = [ + format!("{service_name}.{operation}"), + format!("{short_service}.{operation}"), + call_path.join("."), + ]; + for key in keys { + if let Some(Some(endpoint)) = registry.by_key.get(&key) { + return Some(endpoint.clone()); + } + } + None +} + +fn add_edge_once(dag: &mut Dag, from: &str, from_port: &str, to: &str, to_port: &str) { + if dag.edges.iter().any(|edge| { + edge.from_node.0 == from + && edge.from_port.0 == from_port + && edge.to_node.0 == to + && edge.to_port.0 == to_port + }) { + return; + } + dag.add_edge(Edge::new(from, from_port, to, to_port)); +} + +fn ensure_param_source_node( + dag: &mut Dag, + module_name: &str, + callable: &str, + param: &str, + ty: &str, +) -> String { + let node_id = format!( + "param_source_{}", + sanitize_identifier(&format!("{module_name}_{callable}_{param}")) + ); + add_node_once( + dag, + Node::opaque( + node_id.clone(), + vec![], + vec![Port::with_cardinality(param, ty, Cardinality::ONE)], + LoweredOp::Callable { + module: module_name.to_string(), + kind: CallableKind::Pattern, + name: format!("call_param_source::{callable}::{param}"), + }, + ), + ); + node_id +} + +fn item_callable_body(item: &Item) -> Option<(&str, &[Stmt])> { + match item { + Item::FnDef(def) => Some((def.name.as_str(), def.body.stmts.as_slice())), + Item::FuncDef(def) => Some((def.name.as_str(), def.body.stmts.as_slice())), + Item::PatternDef(def) => Some((def.name.as_str(), def.body.stmts.as_slice())), + _ => None, + } +} + +fn item_callable_uses(item: &Item) -> Option<(&str, &[daglang_syntax::ast::UsesClause])> { + match item { + Item::FuncDef(def) => Some((def.name.as_str(), def.uses.as_slice())), + Item::PatternDef(def) => Some((def.name.as_str(), def.uses.as_slice())), + _ => None, + } +} + +fn item_callable_provides( + item: &Item, +) -> Option<(&str, &[daglang_syntax::ast::ProvidesClause])> { + match item { + Item::FuncDef(def) => Some((def.name.as_str(), def.provides.as_slice())), + _ => None, + } +} + +fn collect_calls_from_stmts(stmts: &[Stmt], calls: &mut HashSet) { + for stmt in stmts { + match stmt { + Stmt::Let(_, expr) | Stmt::Assign(_, expr) | Stmt::Expr(expr) => { + collect_calls_from_expr(expr, calls); + } + Stmt::Return(fields) => { + for (_, expr) in fields { + collect_calls_from_expr(expr, calls); + } + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ServiceCallArgSite { + name: Option, + ident: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ServiceCallSite { + path: Vec, + args: Vec, +} + +fn collect_service_calls_from_stmts(stmts: &[Stmt], calls: &mut Vec) { + for stmt in stmts { + match stmt { + Stmt::Let(_, expr) | Stmt::Assign(_, expr) | Stmt::Expr(expr) => { + collect_service_calls_from_expr(expr, calls); + } + Stmt::Return(fields) => { + for (_, expr) in fields { + collect_service_calls_from_expr(expr, calls); + } + } + } + } +} + +fn collect_calls_from_expr(expr: &Expr, calls: &mut HashSet) { + match expr { + Expr::Call(name, args) => { + if should_track_call(name) { + calls.insert(name.clone()); + } + for (_, arg) in args { + collect_calls_from_expr(arg, calls); + } + } + Expr::ServiceCall(_, args) => { + for (_, arg) in args { + collect_calls_from_expr(arg, calls); + } + } + Expr::FieldAccess(base, _) => collect_calls_from_expr(base, calls), + Expr::BinOp(lhs, _, rhs) => { + collect_calls_from_expr(lhs, calls); + collect_calls_from_expr(rhs, calls); + } + Expr::UnaryOp(_, inner) => collect_calls_from_expr(inner, calls), + Expr::StringInterp(parts) => { + for part in parts { + if let StringPart::Expr(inner) = part { + collect_calls_from_expr(inner, calls); + } + } + } + Expr::Record(_, fields) => { + for (_, value) in fields { + collect_calls_from_expr(value, calls); + } + } + Expr::Match(scrutinee, arms) => { + collect_calls_from_expr(scrutinee, calls); + for arm in arms { + if let Some(guard) = &arm.guard { + collect_calls_from_expr(guard, calls); + } + collect_calls_from_expr(&arm.body, calls); + } + } + Expr::If(cond, then_expr, else_expr) => { + collect_calls_from_expr(cond, calls); + collect_calls_from_expr(then_expr, calls); + if let Some(otherwise) = else_expr { + collect_calls_from_expr(otherwise, calls); + } + } + Expr::For(_, iterable, body) => { + collect_calls_from_expr(iterable, calls); + collect_calls_from_expr(body, calls); + } + Expr::Pipe(lhs, rhs) => { + collect_calls_from_expr(lhs, calls); + collect_calls_from_expr(rhs, calls); + } + Expr::Lambda(_, body) => collect_calls_from_expr(body, calls), + Expr::List(items) => { + for item in items { + collect_calls_from_expr(item, calls); + } + } + Expr::Map(entries) => { + for (key, value) in entries { + collect_calls_from_expr(key, calls); + collect_calls_from_expr(value, calls); + } + } + Expr::Guarded(inner, guard) => { + collect_calls_from_expr(inner, calls); + collect_calls_from_expr(guard, calls); + } + Expr::After(inner, _) => collect_calls_from_expr(inner, calls), + Expr::Return(fields) => { + for (_, value) in fields { + collect_calls_from_expr(value, calls); + } + } + Expr::Literal(_) | Expr::Ident(_) => {} + } +} + +fn collect_service_calls_from_expr(expr: &Expr, calls: &mut Vec) { + match expr { + Expr::Call(_, args) => { + for (_, arg) in args { + collect_service_calls_from_expr(arg, calls); + } + } + Expr::ServiceCall(path, args) => { + calls.push(ServiceCallSite { + path: path.clone(), + args: args + .iter() + .map(|(name, arg)| ServiceCallArgSite { + name: name.clone(), + ident: match arg { + Expr::Ident(ident) => Some(ident.clone()), + _ => None, + }, + }) + .collect::>(), + }); + for (_, arg) in args { + collect_service_calls_from_expr(arg, calls); + } + } + Expr::FieldAccess(base, _) => collect_service_calls_from_expr(base, calls), + Expr::BinOp(lhs, _, rhs) => { + collect_service_calls_from_expr(lhs, calls); + collect_service_calls_from_expr(rhs, calls); + } + Expr::UnaryOp(_, inner) => collect_service_calls_from_expr(inner, calls), + Expr::StringInterp(parts) => { + for part in parts { + if let StringPart::Expr(inner) = part { + collect_service_calls_from_expr(inner, calls); + } + } + } + Expr::Record(_, fields) => { + for (_, value) in fields { + collect_service_calls_from_expr(value, calls); + } + } + Expr::Match(scrutinee, arms) => { + collect_service_calls_from_expr(scrutinee, calls); + for arm in arms { + if let Some(guard) = &arm.guard { + collect_service_calls_from_expr(guard, calls); + } + collect_service_calls_from_expr(&arm.body, calls); + } + } + Expr::If(cond, then_expr, else_expr) => { + collect_service_calls_from_expr(cond, calls); + collect_service_calls_from_expr(then_expr, calls); + if let Some(otherwise) = else_expr { + collect_service_calls_from_expr(otherwise, calls); + } + } + Expr::For(_, iterable, body) => { + collect_service_calls_from_expr(iterable, calls); + collect_service_calls_from_expr(body, calls); + } + Expr::Pipe(lhs, rhs) => { + collect_service_calls_from_expr(lhs, calls); + collect_service_calls_from_expr(rhs, calls); + } + Expr::Lambda(_, body) => collect_service_calls_from_expr(body, calls), + Expr::List(items) => { + for item in items { + collect_service_calls_from_expr(item, calls); + } + } + Expr::Map(entries) => { + for (key, value) in entries { + collect_service_calls_from_expr(key, calls); + collect_service_calls_from_expr(value, calls); + } + } + Expr::Guarded(inner, guard) => { + collect_service_calls_from_expr(inner, calls); + collect_service_calls_from_expr(guard, calls); + } + Expr::After(inner, _) => collect_service_calls_from_expr(inner, calls), + Expr::Return(fields) => { + for (_, value) in fields { + collect_service_calls_from_expr(value, calls); + } + } + Expr::Literal(_) | Expr::Ident(_) => {} + } +} + +fn should_track_call(name: &str) -> bool { + !matches!(name, "" | "as" | "with" | "fn") +} + +#[cfg(test)] +mod tests { + use super::*; + use daglang_resolve::{ModuleGraph, ResolvedModule}; + use daglang_syntax::parser; + use daglang_typecheck::typecheck_module_graph; + use gunbc_ir::{Edge, Port}; + use std::fs; + use std::path::{Path, PathBuf}; + + fn typed_project_from_sources(sources: &[(&str, &str)]) -> TypedProject { + let modules = sources + .iter() + .map(|(path, source)| { + let ast = parser::parse(source).expect("source should parse"); + let module_path = ast + .module_path + .as_ref() + .map(|module| module.node.segments.clone()) + .expect("module declaration is required"); + ResolvedModule { + path: PathBuf::from(path), + ast, + module_path, + dependencies: Vec::new(), + } + }) + .collect(); + typecheck_module_graph(ModuleGraph { modules }).expect("typecheck should succeed") + } + + #[test] + fn lower_makegen_produces_callable_nodes() { + let file = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../dsl/tools/makegen.dag"); + let source = fs::read_to_string(file).expect("should read makegen source"); + let typed = typed_project_from_sources(&[("dsl/tools/makegen.dag", &source)]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + + let node_ids = dag + .nodes + .iter() + .map(|node| node.id.0.as_str()) + .collect::>(); + assert!(node_ids.contains(&"tools.makegen::render_makefile")); + assert!(node_ids.contains(&"tools.makegen::makegen")); + assert!(node_ids.contains(&"prepare_read_makegen")); + assert!(node_ids.contains(&"execute_read_makegen")); + assert!(node_ids.contains(&"compare_makegen_content")); + assert!(node_ids.contains(&"prepare_write_makegen")); + assert!(node_ids.contains(&"execute_makegen_transport")); + assert!(node_ids.contains(&"load_registry")); + assert!(node_ids.contains(&"fs_env")); + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == "tools.makegen::render_makefile" + && edge.to_node.0 == "tools.makegen::makegen" + && edge.to_port.0 == "__deps" + })); + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == "tools.makegen::render_makefile" + && edge.to_node.0 == "compare_makegen_content" + && edge.to_port.0 == "expected_content" + })); + } + + #[test] + fn content_upsert_expansion_wires_transport_chain_for_makegen() { + let file = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../dsl/tools/makegen.dag"); + let source = fs::read_to_string(file).expect("should read makegen source"); + let typed = typed_project_from_sources(&[("dsl/tools/makegen.dag", &source)]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + + assert_eq!( + dag.nodes.len(), + 9, + "expected callable + content_upsert chain + source scaffold nodes" + ); + let required_edges = [ + ( + "prepare_read_makegen", + "request", + "execute_read_makegen", + "request", + ), + ( + "execute_read_makegen", + "response", + "compare_makegen_content", + "response", + ), + ( + "prepare_write_makegen", + "request", + "execute_makegen_transport", + "request", + ), + ( + "compare_makegen_content", + "skip", + "execute_makegen_transport", + "skip", + ), + ( + "execute_makegen_transport", + "makegen_response", + "tools.makegen::makegen", + "__deps", + ), + ("load_registry", "registry", "tools.makegen::render_makefile", "registry"), + ( + "fs_env", + "FilesystemHandle", + "prepare_read_makegen", + "res:file:Makefile", + ), + ]; + for (from_node, from_port, to_node, to_port) in required_edges { + assert!( + dag.edges.iter().any(|edge| { + edge.from_node.0 == from_node + && edge.from_port.0 == from_port + && edge.to_node.0 == to_node + && edge.to_port.0 == to_port + }), + "missing edge {from_node}.{from_port} -> {to_node}.{to_port}" + ); + } + } + + #[test] + fn interface_backed_service_lowers_transport_triplet_nodes() { + let typed = typed_project_from_sources(&[( + "dsl/services/storage.dag", + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +}"#, + )]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + let node_ids = dag + .nodes + .iter() + .map(|node| node.id.0.as_str()) + .collect::>(); + let suffix = "sample_services_FsStorage_read"; + let prepare = format!("prepare_transport_{suffix}"); + let execute = format!("execute_transport_{suffix}"); + let parse = format!("parse_transport_{suffix}"); + + assert!(node_ids.contains(&prepare.as_str())); + assert!(node_ids.contains(&execute.as_str())); + assert!(node_ids.contains(&parse.as_str())); + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == prepare.as_str() + && edge.from_port.0 == "request" + && edge.to_node.0 == execute.as_str() + && edge.to_port.0 == "request" + })); + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == execute.as_str() + && edge.from_port.0 == "response" + && edge.to_node.0 == parse.as_str() + && edge.to_port.0 == "response" + })); + } + + #[test] + fn service_calls_link_parse_triplet_output_into_caller_dependencies() { + let typed = typed_project_from_sources(&[( + "dsl/services/storage_calls.dag", + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path: path) + return { body: response.body } +}"#, + )]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + let parse_node = "parse_transport_sample_services_FsStorage_read"; + let prepare_node = "prepare_transport_sample_services_FsStorage_read"; + let param_source = "param_source_sample_services_run_path"; + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == parse_node + && edge.to_node.0 == "sample.services::run" + && edge.to_port.0 == "__deps" + })); + assert!(dag.nodes.iter().any(|node| node.id.0 == param_source)); + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == param_source + && edge.from_port.0 == "path" + && edge.to_node.0 == prepare_node + && edge.to_port.0 == "path" + })); + } + + #[test] + fn resource_bound_capability_calls_do_not_raise_unresolved_service_call_errors() { + let typed = typed_project_from_sources(&[( + "dsl/resources/resource_capability_calls.dag", + r#"module sample.resources +resource Filesystem { + capability read { + input { path: String } + output { body: String } + } +} +func run(path: String) -> { body: String } uses fs: Filesystem { + let response = fs.read(path: path) + return { body: response.body } +}"#, + )]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + assert!(dag + .nodes + .iter() + .any(|node| node.id.0 == "sample.resources::run")); + } + + #[test] + fn unresolved_service_call_reports_lower_error() { + let typed = typed_project_from_sources(&[( + "dsl/services/unresolved_call.dag", + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = MissingStorage.read(path: path) + return { body: response.body } +}"#, + )]); + let error = lower_typed_project(&typed).expect_err("lowering should fail"); + assert!(matches!( + error, + LowerError::UnresolvedServiceCall { caller, service_call } + if caller == "sample.services::run" && service_call == "MissingStorage.read" + )); + } + + #[test] + fn positional_service_call_args_wire_by_operation_input_order() { + let typed = typed_project_from_sources(&[( + "dsl/services/storage_calls_positional.dag", + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path) + return { body: response.body } +}"#, + )]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == "param_source_sample_services_run_path" + && edge.from_port.0 == "path" + && edge.to_node.0 == "prepare_transport_sample_services_FsStorage_read" + && edge.to_port.0 == "path" + })); + } + + #[test] + fn resource_acquire_release_lower_to_lifecycle_nodes() { + let typed = typed_project_from_sources(&[( + "dsl/resources/fs.dag", + r#"module sample.resources +resource TempFile { + acquire { + let path = "/tmp/file" + } + release { + let done = true + } +} +func run() -> { ok: Bool } { + return { ok: true } +}"#, + )]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + let acquire = "acquire_resource_sample_resources_TempFile"; + let release = "release_resource_sample_resources_TempFile"; + assert!(dag.nodes.iter().any(|node| node.id.0 == acquire)); + assert!(dag.nodes.iter().any(|node| node.id.0 == release)); + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == acquire + && edge.from_port.0 == "resource_handle" + && edge.to_node.0 == release + && edge.to_port.0 == "resource_handle" + })); + } + + #[test] + fn uses_clause_wires_acquire_and_release_lifecycle_edges() { + let typed = typed_project_from_sources(&[( + "dsl/resources/uses_wiring.dag", + r#"module sample.resources +resource TempFile { + acquire { let path = "/tmp/file" } + release { let done = true } +} +func run() -> { ok: Bool } uses fs: TempFile { + return { ok: true } +}"#, + )]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + let acquire = "acquire_resource_sample_resources_TempFile"; + let release = "release_resource_sample_resources_TempFile"; + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == acquire + && edge.from_port.0 == "resource_handle" + && edge.to_node.0 == "sample.resources::run" + && edge.to_port.0 == "__deps" + })); + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == "sample.resources::run" + && edge.from_port.0 == "ok" + && edge.to_node.0 == release + && edge.to_port.0 == "resource_handle" + })); + } + + #[test] + fn unresolved_uses_clause_reports_lower_error() { + let typed = typed_project_from_sources(&[( + "dsl/resources/unresolved_uses.dag", + r#"module sample.resources +func run() -> { ok: Bool } uses fs: MissingResource { + return { ok: true } +}"#, + )]); + let error = lower_typed_project(&typed).expect_err("lowering should fail"); + assert!(matches!( + error, + LowerError::UnresolvedUsedResource { + caller, + binding, + resource_type, + } if caller == "sample.resources::run" + && binding == "fs" + && resource_type == "MissingResource" + )); + } + + #[test] + fn uses_clause_with_runtime_config_suffix_resolves_resource_type() { + let typed = typed_project_from_sources(&[( + "dsl/resources/configured_uses_wiring.dag", + r#"module sample.resources +resource TempFile { + acquire { let path = "/tmp/file" } + release { let done = true } +} +func run() -> { ok: Bool } uses fs: TempFile(mode: ReadWrite) { + return { ok: true } +}"#, + )]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == "acquire_resource_sample_resources_TempFile" + && edge.from_port.0 == "resource_handle" + && edge.to_node.0 == "sample.resources::run" + && edge.to_port.0 == "__deps" + })); + } + + #[test] + fn provides_clause_adds_provider_node_and_edge() { + let typed = typed_project_from_sources(&[( + "dsl/resources/provides_wiring.dag", + r#"module sample.resources +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +func run() -> { ok: Bool } provides out: Storage { + return { ok: true } +}"#, + )]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + let provider = "provide_resource_sample_resources_run_out"; + assert!(dag.nodes.iter().any(|node| node.id.0 == provider)); + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == "sample.resources::run" + && edge.from_port.0 == "ok" + && edge.to_node.0 == provider + && edge.to_port.0 == "trigger" + })); + } + + #[test] + fn unresolved_provides_clause_reports_lower_error() { + let typed = typed_project_from_sources(&[( + "dsl/resources/unresolved_provides.dag", + r#"module sample.resources +func run() -> { ok: Bool } provides out: MissingResource { + return { ok: true } +}"#, + )]); + let error = lower_typed_project(&typed).expect_err("lowering should fail"); + assert!(matches!( + error, + LowerError::UnresolvedProvidedResource { + caller, + binding, + resource_type, + } if caller == "sample.resources::run" + && binding == "out" + && resource_type == "MissingResource" + )); + } + + #[test] + fn provides_clause_with_runtime_config_suffix_resolves_resource_type() { + let typed = typed_project_from_sources(&[( + "dsl/resources/configured_provides_wiring.dag", + r#"module sample.resources +resource TempFile { + release { + let done = true + } +} +func run() -> { ok: Bool } provides file: TempFile(mode: ReadWrite) { + return { ok: true } +}"#, + )]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == "provide_resource_sample_resources_run_file" + && edge.from_port.0 == "file" + && edge.to_node.0 == "release_resource_sample_resources_TempFile" + && edge.to_port.0 == "resource_handle" + })); + } + + #[test] + fn provides_resource_with_release_wires_provider_output_to_lifecycle_release() { + let typed = typed_project_from_sources(&[( + "dsl/resources/provides_release_wiring.dag", + r#"module sample.resources +resource TempFile { + release { + let done = true + } +} +func run() -> { ok: Bool } provides file: TempFile { + return { ok: true } +}"#, + )]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + assert!(dag.edges.iter().any(|edge| { + edge.from_node.0 == "provide_resource_sample_resources_run_file" + && edge.from_port.0 == "file" + && edge.to_node.0 == "release_resource_sample_resources_TempFile" + && edge.to_port.0 == "resource_handle" + })); + } + + #[test] + fn known_interface_uses_without_lifecycle_are_tolerated() { + let typed = typed_project_from_sources(&[( + "dsl/resources/interface_uses.dag", + r#"module sample.resources +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +func run() -> { ok: Bool } uses store: Storage { + return { ok: true } +}"#, + )]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + assert!(dag.nodes.iter().any(|node| node.id.0 == "sample.resources::run")); + assert!( + !dag.edges.iter().any(|edge| edge.to_node.0 == "sample.resources::run" + && edge.to_port.0 == "__deps"), + "interface-only uses should not fabricate lifecycle dependency edges" + ); + } + + #[test] + fn known_interface_provides_without_lifecycle_are_tolerated() { + let typed = typed_project_from_sources(&[( + "dsl/resources/interface_provides.dag", + r#"module sample.resources +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +func run() -> { ok: Bool } provides out: Storage { + return { ok: true } +}"#, + )]); + let dag = lower_typed_project(&typed).expect("lowering should succeed"); + assert!(dag + .nodes + .iter() + .any(|node| node.id.0 == "provide_resource_sample_resources_run_out")); + assert!( + !dag.edges.iter().any(|edge| { + edge.from_node.0 == "provide_resource_sample_resources_run_out" + && edge.to_port.0 == "resource_handle" + }), + "interface-only provides should not fabricate lifecycle release edges" + ); + } + + #[test] + fn lower_errors_when_no_callable_items_exist() { + let typed = typed_project_from_sources(&[( + "dsl/types_only.dag", + "module sample.types\ntype Name = String", + )]); + let error = lower_typed_project(&typed).expect_err("should fail without callable items"); + assert!(matches!(error, LowerError::NoLowerableItems)); + } + + #[test] + fn makegen_parity_report_is_deterministic() { + let file = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../dsl/tools/makegen.dag"); + let source = fs::read_to_string(file).expect("should read makegen source"); + let typed = typed_project_from_sources(&[("dsl/tools/makegen.dag", &source)]); + let lowered = lower_typed_project(&typed).expect("lowering should succeed"); + let reference = reference_makegen_shape(); + + let report_a = compare_topology(&lowered, &reference); + let report_b = compare_topology(&lowered, &reference); + assert_eq!(report_a, report_b, "parity reports must be deterministic"); + assert!( + !report_a.is_exact_match(), + "phase-1 scaffold should still report makegen parity deltas" + ); + assert!( + report_a.added_nodes + report_a.removed_nodes + report_a.changed_nodes > 0, + "parity report should continue surfacing remaining topology differences" + ); + } + + #[test] + fn makegen_normalized_parity_can_reach_exact_match() { + let file = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../dsl/tools/makegen.dag"); + let source = fs::read_to_string(file).expect("should read makegen source"); + let typed = typed_project_from_sources(&[("dsl/tools/makegen.dag", &source)]); + let lowered = lower_typed_project(&typed).expect("lowering should succeed"); + let reference = reference_makegen_shape(); + + let report = compare_makegen_topology(&lowered, &reference); + assert!( + report.is_exact_match(), + "normalized makegen parity should currently match reference topology" + ); + } + + fn reference_makegen_shape() -> Dag<()> { + let mut dag = Dag::new(); + dag.add_node(Node::opaque( + "fs_env", + vec![], + vec![Port::scalar("FilesystemHandle", "FilesystemHandle")], + (), + )); + dag.add_node(Node::opaque( + "load_registry", + vec![], + vec![Port::scalar("registry", "ToolRegistry")], + (), + )); + dag.add_node(Node::opaque( + "render_makefile", + vec![Port::scalar("registry", "ToolRegistry")], + vec![Port::scalar("makefile_content", "String")], + (), + )); + dag.add_node(Node::opaque( + "prepare_read_makegen", + vec![ + Port::scalar("path", "String"), + Port::scalar("res:file:Makefile", "FilesystemHandle"), + ], + vec![Port::scalar("request", "TransportRequest")], + (), + )); + dag.add_node(Node::opaque( + "execute_read_makegen", + vec![Port::scalar("request", "TransportRequest")], + vec![Port::scalar("response", "TransportResponse")], + (), + )); + dag.add_node(Node::opaque( + "compare_makegen_content", + vec![ + Port::scalar("expected_content", "String"), + Port::scalar("response", "TransportResponse"), + ], + vec![Port::scalar("fresh", "Bool"), Port::scalar("skip", "Bool")], + (), + )); + dag.add_node(Node::opaque( + "prepare_write_makegen", + vec![Port::scalar("content", "String"), Port::scalar("path", "String")], + vec![Port::scalar("request", "TransportRequest")], + (), + )); + dag.add_node(Node::opaque( + "execute_makegen_transport", + vec![ + Port::scalar("request", "TransportRequest"), + Port::scalar("skip", "Bool"), + ], + vec![Port::scalar("makegen_response", "TransportResponse")], + (), + )); + + dag.add_edge(Edge::new( + "load_registry", + "registry", + "render_makefile", + "registry", + )); + dag.add_edge(Edge::new( + "render_makefile", + "makefile_content", + "compare_makegen_content", + "expected_content", + )); + dag.add_edge(Edge::new( + "render_makefile", + "makefile_content", + "prepare_write_makegen", + "content", + )); + dag.add_edge(Edge::new( + "prepare_read_makegen", + "request", + "execute_read_makegen", + "request", + )); + dag.add_edge(Edge::new( + "execute_read_makegen", + "response", + "compare_makegen_content", + "response", + )); + dag.add_edge(Edge::new( + "prepare_write_makegen", + "request", + "execute_makegen_transport", + "request", + )); + dag.add_edge(Edge::new( + "compare_makegen_content", + "skip", + "execute_makegen_transport", + "skip", + )); + dag + } } diff --git a/core/daglang/daglang-resolve/src/lib.rs b/core/daglang/daglang-resolve/src/lib.rs index 0d30c0bc7de..58d4d7da58e 100644 --- a/core/daglang/daglang-resolve/src/lib.rs +++ b/core/daglang/daglang-resolve/src/lib.rs @@ -25,6 +25,8 @@ use daglang_syntax::ast::SourceFile; use daglang_syntax::diagnostic::Diagnostic; use daglang_syntax::parser; +type ParsedModuleRecord = (PathBuf, Vec, Vec>, SourceFile); + /// A resolved module in the dependency graph. #[derive(Debug)] pub struct ResolvedModule { @@ -77,7 +79,7 @@ impl ModuleGraph { dag_files.dedup(); let canonical_roots = canonicalize_roots(roots); - let mut parsed: Vec<(PathBuf, Vec, Vec>, SourceFile)> = Vec::new(); + let mut parsed: Vec = Vec::new(); let mut parse_errors: Vec<(PathBuf, Vec)> = Vec::new(); for path in &dag_files { @@ -286,8 +288,8 @@ fn topo_sort(modules: &mut Vec) -> Result<(), ResolveError> { for idx in &order { seen[*idx] = true; } - for idx in 0..n { - if !seen[idx] { + for (idx, visited) in seen.iter().enumerate().take(n) { + if !visited { order.push(idx); } } diff --git a/core/daglang/daglang-syntax/src/lexer.rs b/core/daglang/daglang-syntax/src/lexer.rs index 6bf4f6a8c79..0e6b26b266a 100644 --- a/core/daglang/daglang-syntax/src/lexer.rs +++ b/core/daglang/daglang-syntax/src/lexer.rs @@ -447,8 +447,8 @@ mod tests { #[test] fn numbers() { - assert_eq!(kinds("42 3.14"), vec![ - TokenKind::Int(42), TokenKind::Float(3.14), TokenKind::Eof, + assert_eq!(kinds("42 2.5"), vec![ + TokenKind::Int(42), TokenKind::Float(2.5), TokenKind::Eof, ]); } diff --git a/core/daglang/daglang-syntax/src/lib.rs b/core/daglang/daglang-syntax/src/lib.rs index cce38975e37..209d632c063 100644 --- a/core/daglang/daglang-syntax/src/lib.rs +++ b/core/daglang/daglang-syntax/src/lib.rs @@ -130,6 +130,7 @@ pub mod ast { #[derive(Debug)] pub struct FnDef { pub name: String, + pub type_params: Vec, pub params: Vec, pub return_type: TypeExpr, pub body: FnBody, @@ -139,6 +140,7 @@ pub mod ast { #[derive(Debug)] pub struct FuncDef { pub name: String, + pub type_params: Vec, pub params: Vec, pub outputs: Vec, pub uses: Vec, @@ -151,6 +153,7 @@ pub mod ast { #[derive(Debug)] pub struct PatternDef { pub name: String, + pub type_params: Vec, pub params: Vec, pub outputs: Vec, pub uses: Vec, @@ -341,12 +344,14 @@ pub mod ast { #[derive(Debug, Clone)] pub struct FnBody { pub stmts: Vec, + pub lossy: bool, } /// Effectful function body (can include service calls, resource ops). #[derive(Debug, Clone)] pub struct FuncBody { pub stmts: Vec, + pub lossy: bool, } /// Statement in a function body. diff --git a/core/daglang/daglang-syntax/src/parser.rs b/core/daglang/daglang-syntax/src/parser.rs index 8f736ec9267..7579d3efef6 100644 --- a/core/daglang/daglang-syntax/src/parser.rs +++ b/core/daglang/daglang-syntax/src/parser.rs @@ -556,11 +556,23 @@ impl Parser { params.push(self.expect_ident()?); // optional bounds `: Bound + Bound` if self.eat(&TokenKind::Colon) { + if self.eat(&TokenKind::Arrow) { + let output_type = self.expect_ident()?; + if !params.contains(&output_type) { + params.push(output_type); + } + } self.skip_type_bound(); } while self.eat(&TokenKind::Comma) { params.push(self.expect_ident()?); if self.eat(&TokenKind::Colon) { + if self.eat(&TokenKind::Arrow) { + let output_type = self.expect_ident()?; + if !params.contains(&output_type) { + params.push(output_type); + } + } self.skip_type_bound(); } } @@ -702,20 +714,23 @@ impl Parser { fn parse_fn_def(&mut self) -> Result { self.expect(&TokenKind::Fn)?; let name = self.expect_ident()?; - let _tp = self.parse_optional_type_params()?; + let type_params = self.parse_optional_type_params()?; self.expect(&TokenKind::LParen)?; let params = self.parse_param_list()?; self.expect(&TokenKind::RParen)?; self.expect(&TokenKind::Arrow)?; let return_type = self.parse_return_type_expr()?; let body = if self.eat(&TokenKind::LBrace) { - self.consume_brace_block_contents()?; - FnBody { stmts: Vec::new() } + self.parse_fn_body_lossy()? } else { - FnBody { stmts: Vec::new() } + FnBody { + stmts: Vec::new(), + lossy: false, + } }; Ok(FnDef { name, + type_params, params, return_type, body, @@ -725,7 +740,7 @@ impl Parser { fn parse_func_def(&mut self, leading: Vec) -> Result { self.expect(&TokenKind::Func)?; let name = self.expect_ident()?; - let _tp = self.parse_optional_type_params()?; + let type_params = self.parse_optional_type_params()?; self.expect(&TokenKind::LParen)?; let params = self.parse_param_list()?; self.expect(&TokenKind::RParen)?; @@ -737,10 +752,10 @@ impl Parser { annotations.push(self.parse_annotation()?); } self.expect(&TokenKind::LBrace)?; - self.consume_brace_block_contents()?; - let body = FuncBody { stmts: Vec::new() }; + let body = self.parse_func_body_lossy()?; Ok(FuncDef { name, + type_params, params, outputs, uses, @@ -753,7 +768,7 @@ impl Parser { fn parse_pattern_def(&mut self) -> Result { self.expect(&TokenKind::Pattern)?; let name = self.expect_ident()?; - let _tp = self.parse_optional_type_params()?; + let type_params = self.parse_optional_type_params()?; self.expect(&TokenKind::LParen)?; let params = self.parse_param_list()?; self.expect(&TokenKind::RParen)?; @@ -761,10 +776,10 @@ impl Parser { let outputs = self.parse_output_fields()?; let (uses, _provides) = self.parse_uses_provides()?; self.expect(&TokenKind::LBrace)?; - self.consume_brace_block_contents()?; - let body = FuncBody { stmts: Vec::new() }; + let body = self.parse_func_body_lossy()?; Ok(PatternDef { name, + type_params, params, outputs, uses, @@ -896,11 +911,9 @@ impl Parser { inputs = self.parse_field_list_until_rparen()?; self.expect(&TokenKind::RParen)?; } - if self.eat(&TokenKind::Arrow) { - if self.eat(&TokenKind::LBrace) { - outputs = self.parse_field_list_until_rbrace()?; - self.expect(&TokenKind::RBrace)?; - } + if self.eat(&TokenKind::Arrow) && self.eat(&TokenKind::LBrace) { + outputs = self.parse_field_list_until_rbrace()?; + self.expect(&TokenKind::RBrace)?; } while self.check(&TokenKind::At) { annotations.push(self.parse_annotation()?); @@ -953,15 +966,13 @@ impl Parser { TokenKind::Acquire => { self.advance(); self.expect(&TokenKind::LBrace)?; - self.consume_brace_block_contents()?; - let body = FuncBody { stmts: Vec::new() }; + let body = self.parse_func_body_lossy()?; acquire = Some(body); } TokenKind::Release => { self.advance(); self.expect(&TokenKind::LBrace)?; - self.consume_brace_block_contents()?; - let body = FuncBody { stmts: Vec::new() }; + let body = self.parse_func_body_lossy()?; release = Some(body); } TokenKind::Capability => { @@ -1206,8 +1217,7 @@ impl Parser { self.expect(&TokenKind::RBracket)?; } self.expect(&TokenKind::LBrace)?; - self.consume_brace_block_contents()?; - let body = FuncBody { stmts: Vec::new() }; + let body = self.parse_func_body_lossy()?; Ok(StageDef { name, body, after }) } @@ -1228,6 +1238,46 @@ impl Parser { } } + fn parse_fn_body_lossy(&mut self) -> Result { + let start_pos = self.pos; + let start_errors = self.errors.len(); + let parsed = self.parse_fn_body(); + + let should_fallback = parsed.is_err() || self.errors.len() > start_errors; + if !should_fallback { + self.expect(&TokenKind::RBrace)?; + return parsed; + } + + self.pos = start_pos; + self.errors.truncate(start_errors); + self.consume_brace_block_contents()?; + Ok(FnBody { + stmts: Vec::new(), + lossy: true, + }) + } + + fn parse_func_body_lossy(&mut self) -> Result { + let start_pos = self.pos; + let start_errors = self.errors.len(); + let parsed = self.parse_func_body(); + + let should_fallback = parsed.is_err() || self.errors.len() > start_errors; + if !should_fallback { + self.expect(&TokenKind::RBrace)?; + return parsed; + } + + self.pos = start_pos; + self.errors.truncate(start_errors); + self.consume_brace_block_contents()?; + Ok(FuncBody { + stmts: Vec::new(), + lossy: true, + }) + } + // ── fields / params ──────────────────────────────────────────── fn parse_field_list_until_rbrace(&mut self) -> Result, ParseError> { @@ -1363,9 +1413,8 @@ impl Parser { } if self.eat(&TokenKind::Colon) { - match self.parse_expr(0) { - Ok(e) => args.push(e), - Err(_) => {} + if let Ok(e) = self.parse_expr(0) { + args.push(e); } self.skip_annotation_value(); } @@ -1427,12 +1476,14 @@ impl Parser { fn parse_fn_body(&mut self) -> Result { Ok(FnBody { stmts: self.parse_stmts()?, + lossy: false, }) } fn parse_func_body(&mut self) -> Result { Ok(FuncBody { stmts: self.parse_stmts()?, + lossy: false, }) } @@ -2085,7 +2136,7 @@ impl Parser { ))); }; self.advance(); - if name.chars().next().map_or(false, |c| c.is_uppercase()) { + if name.chars().next().is_some_and(|c| c.is_uppercase()) { if self.eat(&TokenKind::LParen) { let mut fields = Vec::new(); let mut positional_index = 0usize; @@ -2235,7 +2286,62 @@ mod tests { let sf = parse_or_panic("module test\nfn greet(name: String) -> String { name }"); assert_eq!(sf.items.len(), 1); match &sf.items[0].node { - Item::FnDef(f) => assert_eq!(f.name, "greet"), + Item::FnDef(f) => { + assert_eq!(f.name, "greet"); + assert!(f.type_params.is_empty()); + } + other => panic!("expected FnDef, got {other:?}"), + } + } + + #[test] + fn parse_fn_type_params_are_preserved() { + let sf = parse_or_panic("module test\nfn identity(value: T) -> T { value }"); + match &sf.items[0].node { + Item::FnDef(f) => assert_eq!(f.type_params, vec!["T".to_string()]), + other => panic!("expected FnDef, got {other:?}"), + } + } + + #[test] + fn parse_pattern_type_params_capture_arrow_output_variable() { + let sf = parse_or_panic( + "module test\npattern upsert R>(value: Check) -> { value: R } { return { value: value } }", + ); + match &sf.items[0].node { + Item::PatternDef(p) => { + assert_eq!( + p.type_params, + vec![ + "Check".to_string(), + "Create".to_string(), + "Resolve".to_string(), + "R".to_string() + ] + ); + } + other => panic!("expected PatternDef, got {other:?}"), + } + } + + #[test] + fn parse_fn_body_marks_lossy_for_unsupported_match_arms() { + let sf = parse_or_panic( + r#"module test +type CloudConfig = GcpConfig { project: String } | AwsConfig { account: String } +type CloudProvider = Gcp | Aws +fn provider_of(config: CloudConfig) -> CloudProvider { + match config { + GcpConfig { ... } => Gcp + AwsConfig { ... } => Aws + } +}"#, + ); + match &sf.items[2].node { + Item::FnDef(f) => { + assert!(f.body.lossy); + assert!(f.body.stmts.is_empty()); + } other => panic!("expected FnDef, got {other:?}"), } } diff --git a/core/daglang/daglang-syntax/tests/representative_ast.rs b/core/daglang/daglang-syntax/tests/representative_ast.rs index 3634f7ad9ca..6f94514d50c 100644 --- a/core/daglang/daglang-syntax/tests/representative_ast.rs +++ b/core/daglang/daglang-syntax/tests/representative_ast.rs @@ -49,6 +49,32 @@ fn makegen_contains_fn_and_func_items() { .items .iter() .any(|item| matches!(item.node, Item::FuncDef(_)))); + + let render_fn = source + .items + .iter() + .find_map(|item| match &item.node { + Item::FnDef(def) if def.name == "render_makefile" => Some(def), + _ => None, + }) + .expect("render_makefile fn should exist"); + assert!( + !render_fn.body.stmts.is_empty(), + "render_makefile body should retain parsed statements" + ); + + let makegen_func = source + .items + .iter() + .find_map(|item| match &item.node { + Item::FuncDef(def) if def.name == "makegen" => Some(def), + _ => None, + }) + .expect("makegen func should exist"); + assert!( + !makegen_func.body.stmts.is_empty(), + "makegen func body should retain parsed statements" + ); assert_eq!( item_signatures(&source), vec!["fn render_makefile", "func makegen"] diff --git a/core/daglang/daglang-typecheck/src/lib.rs b/core/daglang/daglang-typecheck/src/lib.rs index 12fbd3b8cb9..ac760c98c60 100644 --- a/core/daglang/daglang-typecheck/src/lib.rs +++ b/core/daglang/daglang-typecheck/src/lib.rs @@ -20,6 +20,85 @@ //! - `@contract` annotation validation (behavioral specs are well-typed) //! - Subtyping via the bounded lattice (§4.1.4 of dsl-design.md) +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use daglang_resolve::{ModuleGraph, ResolvedModule}; +use daglang_syntax::ast::{ + Expr, Field, Item, Param, ProvidesClause, SourceFile, Stmt, TypeBody, TypeExpr, UsesClause, +}; + +/// A typechecked project snapshot. +#[derive(Debug)] +pub struct TypedProject { + pub modules: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub struct TypecheckOptions { + pub allow_unresolved_imports: bool, +} + +impl Default for TypecheckOptions { + fn default() -> Self { + Self { + allow_unresolved_imports: true, + } + } +} + +/// A typechecked module. +#[derive(Debug)] +pub struct TypedModule { + pub path: PathBuf, + pub module_path: Vec, + pub imports: Vec>, + pub ast: SourceFile, + pub signatures: Vec, +} + +/// A normalized signature captured from a top-level item. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TypedItemSignature { + Type { + name: String, + }, + Fn(TypedCallableSignature), + Func(TypedCallableSignature), + Pattern(TypedCallableSignature), + Service { + name: String, + operations: usize, + }, + Resource { + name: String, + implements: Option, + }, + Interface { + name: String, + capabilities: usize, + }, + Pipeline { + name: String, + stages: usize, + }, +} + +/// A normalized callable signature for fn/func/pattern items. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TypedCallableSignature { + pub name: String, + pub params: Vec, + pub outputs: Vec, +} + +/// A single typed binding in a callable signature. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TypedBinding { + pub name: String, + pub ty: String, +} + /// Errors during type checking. #[derive(Debug)] pub enum TypeError { @@ -39,4 +118,4821 @@ pub enum TypeError { UnsatisfiableRefinement { ty: String, constraint: String }, /// Generic type parameter count mismatch. ArityMismatch { name: String, expected: usize, got: usize }, + /// Duplicate top-level item name in a module. + DuplicateDefinition { module: String, name: String }, + /// Duplicate parameter name in a callable signature. + DuplicateParameter { item: String, param: String }, + /// Duplicate output field name in a callable signature. + DuplicateOutputField { item: String, field: String }, + /// Import target does not exist in the available module graph. + UnresolvedImport { module: String, target: String }, + /// Resource/service declares an interface that cannot be resolved. + UnresolvedInterface { + implementor: String, + interface: String, + }, + /// Resource/service declares an interface that resolves ambiguously. + AmbiguousInterface { + implementor: String, + interface: String, + }, + /// Service omits an operation required by its interface. + MissingOperation { + service: String, + interface: String, + operation: String, + }, + /// Implementor signature does not match interface contract. + InterfaceSignatureMismatch { + implementor: String, + interface: String, + capability: String, + detail: String, + }, + /// Call expression used wrong number of arguments. + CallArityMismatch { + caller: String, + callee: String, + expected: usize, + got: usize, + }, + /// Call expression used an unknown named argument. + UnknownCallArgument { + caller: String, + callee: String, + argument: String, + }, + /// Call expression reuses the same named argument multiple times. + DuplicateCallArgument { + caller: String, + callee: String, + argument: String, + }, + /// Call expression target resolves to multiple callable contracts. + AmbiguousCallTarget { + caller: String, + callee: String, + }, + /// Call expression target cannot be resolved to a callable contract. + UnresolvedCallTarget { + caller: String, + callee: String, + }, + /// Service call expression used wrong number of arguments. + ServiceCallArityMismatch { + caller: String, + service_call: String, + expected: usize, + got: usize, + }, + /// Service call target could not be resolved to a known service operation contract. + UnresolvedServiceCall { caller: String, service_call: String }, + /// Service call target matches multiple possible service operation contracts. + AmbiguousServiceCall { caller: String, service_call: String }, + /// Service call expression used an unknown named argument. + UnknownServiceCallArgument { + caller: String, + service_call: String, + argument: String, + }, + /// Service call expression reuses the same named argument multiple times. + DuplicateServiceCallArgument { + caller: String, + service_call: String, + argument: String, + }, + /// `uses` clause references an unknown resource/interface type. + UnknownUsedResourceType { + item: String, + binding: String, + resource_type: String, + }, + /// `uses` clause references an ambiguous short resource/interface type. + AmbiguousUsedResourceType { + item: String, + binding: String, + resource_type: String, + }, + /// Duplicate `uses` binding within a callable declaration. + DuplicateUsesBinding { item: String, binding: String }, + /// Duplicate `provides` binding within a callable declaration. + DuplicateProvidesBinding { item: String, binding: String }, + /// A binding is declared in both `uses` and `provides`. + UseProvideBindingConflict { item: String, binding: String }, + /// `provides` clause references an unknown resource/interface type. + UnknownProvidedResourceType { + item: String, + binding: String, + resource_type: String, + }, + /// `provides` clause references an ambiguous short resource/interface type. + AmbiguousProvidedResourceType { + item: String, + binding: String, + resource_type: String, + }, +} + +impl std::fmt::Display for TypeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UndefinedType(name) => write!(f, "undefined type `{name}`"), + Self::NoSuchField { ty, field } => { + write!(f, "type `{ty}` has no field `{field}`") + } + Self::TypeMismatch { expected, got } => { + write!(f, "type mismatch: expected `{expected}`, got `{got}`") + } + Self::MissingCapability { + resource, + interface, + capability, + } => write!( + f, + "resource `{resource}` is missing capability `{capability}` for interface `{interface}`" + ), + Self::UnsatisfiableRefinement { ty, constraint } => { + write!(f, "unsatisfiable refinement on `{ty}`: {constraint}") + } + Self::ArityMismatch { + name, + expected, + got, + } => write!( + f, + "generic arity mismatch for `{name}`: expected {expected}, got {got}" + ), + Self::DuplicateDefinition { module, name } => { + write!(f, "duplicate definition `{name}` in module `{module}`") + } + Self::DuplicateParameter { item, param } => { + write!(f, "duplicate parameter `{param}` in `{item}`") + } + Self::DuplicateOutputField { item, field } => { + write!(f, "duplicate output field `{field}` in `{item}`") + } + Self::UnresolvedImport { module, target } => { + write!(f, "unresolved import `{target}` in module `{module}`") + } + Self::UnresolvedInterface { + implementor, + interface, + } => write!( + f, + "`{implementor}` references unresolved interface `{interface}`" + ), + Self::AmbiguousInterface { + implementor, + interface, + } => write!( + f, + "`{implementor}` references ambiguous interface `{interface}`" + ), + Self::MissingOperation { + service, + interface, + operation, + } => write!( + f, + "service `{service}` is missing operation `{operation}` for interface `{interface}`" + ), + Self::InterfaceSignatureMismatch { + implementor, + interface, + capability, + detail, + } => write!( + f, + "`{implementor}` does not match `{interface}.{capability}` contract: {detail}" + ), + Self::CallArityMismatch { + caller, + callee, + expected, + got, + } => write!( + f, + "call arity mismatch in `{caller}` for `{callee}`: expected {expected}, got {got}" + ), + Self::UnknownCallArgument { + caller, + callee, + argument, + } => write!( + f, + "unknown named argument `{argument}` in call to `{callee}` within `{caller}`" + ), + Self::DuplicateCallArgument { + caller, + callee, + argument, + } => write!( + f, + "duplicate named argument `{argument}` in call to `{callee}` within `{caller}`" + ), + Self::AmbiguousCallTarget { caller, callee } => write!( + f, + "ambiguous call target `{callee}` in `{caller}`" + ), + Self::UnresolvedCallTarget { caller, callee } => write!( + f, + "unresolved call target `{callee}` in `{caller}`" + ), + Self::ServiceCallArityMismatch { + caller, + service_call, + expected, + got, + } => write!( + f, + "service call arity mismatch in `{caller}` for `{service_call}`: expected {expected}, got {got}" + ), + Self::UnresolvedServiceCall { + caller, + service_call, + } => write!( + f, + "unresolved service call `{service_call}` in `{caller}`" + ), + Self::AmbiguousServiceCall { + caller, + service_call, + } => write!( + f, + "ambiguous service call `{service_call}` in `{caller}`" + ), + Self::UnknownServiceCallArgument { + caller, + service_call, + argument, + } => write!( + f, + "unknown named argument `{argument}` in service call `{service_call}` within `{caller}`" + ), + Self::DuplicateServiceCallArgument { + caller, + service_call, + argument, + } => write!( + f, + "duplicate named argument `{argument}` in service call `{service_call}` within `{caller}`" + ), + Self::UnknownUsedResourceType { + item, + binding, + resource_type, + } => write!( + f, + "unknown used resource type `{resource_type}` for binding `{binding}` in `{item}`" + ), + Self::AmbiguousUsedResourceType { + item, + binding, + resource_type, + } => write!( + f, + "ambiguous used resource type `{resource_type}` for binding `{binding}` in `{item}`" + ), + Self::DuplicateUsesBinding { item, binding } => write!( + f, + "duplicate uses binding `{binding}` in `{item}`" + ), + Self::DuplicateProvidesBinding { item, binding } => write!( + f, + "duplicate provides binding `{binding}` in `{item}`" + ), + Self::UseProvideBindingConflict { item, binding } => write!( + f, + "binding `{binding}` is declared in both uses/provides in `{item}`" + ), + Self::UnknownProvidedResourceType { + item, + binding, + resource_type, + } => write!( + f, + "unknown provided resource type `{resource_type}` for binding `{binding}` in `{item}`" + ), + Self::AmbiguousProvidedResourceType { + item, + binding, + resource_type, + } => write!( + f, + "ambiguous provided resource type `{resource_type}` for binding `{binding}` in `{item}`" + ), + } + } +} + +/// Typecheck a discovered module graph and produce typed module signatures. +pub fn typecheck_module_graph(graph: ModuleGraph) -> Result> { + typecheck_module_graph_with_options(graph, TypecheckOptions::default()) +} + +/// Typecheck a discovered module graph with explicit options. +pub fn typecheck_module_graph_with_options( + graph: ModuleGraph, + options: TypecheckOptions, +) -> Result> { + let known_types = collect_known_types(&graph.modules); + let generic_arity_registry = collect_generic_arities(&graph.modules); + let record_type_registry = collect_record_types(&graph.modules); + let callable_registry = collect_unique_callables(&graph.modules); + let pattern_callable_names = collect_pattern_callable_names(&graph.modules); + let service_call_registry = collect_service_call_contracts(&graph.modules); + let interface_registry = collect_interfaces(&graph.modules); + let resource_type_registry = collect_resource_types(&graph.modules); + let resource_capability_registry = collect_resource_capabilities(&graph.modules); + let available_modules = graph + .modules + .iter() + .map(|module| module.module_path.join(".")) + .collect::>(); + let mut errors = Vec::new(); + let mut typed_modules = Vec::with_capacity(graph.modules.len()); + let context = TypecheckContext { + known_types: &known_types, + generic_arity_registry: &generic_arity_registry, + record_type_registry: &record_type_registry, + callable_registry: &callable_registry, + pattern_callable_names: &pattern_callable_names, + service_call_registry: &service_call_registry, + interface_registry: &interface_registry, + resource_type_registry: &resource_type_registry, + resource_capability_registry: &resource_capability_registry, + allow_unresolved_references: options.allow_unresolved_imports, + }; + + for module in graph.modules { + let imports = module + .ast + .imports + .iter() + .map(|import| import.node.path.segments.clone()) + .collect::>(); + let module_name = module.module_path.join("."); + if !options.allow_unresolved_imports { + for import in &imports { + let target = import.join("."); + if !available_modules.contains(&target) { + errors.push(TypeError::UnresolvedImport { + module: module_name.clone(), + target, + }); + } + } + } + let signatures = collect_signatures(&module, &context, &module_name, &mut errors); + typed_modules.push(TypedModule { + path: module.path, + module_path: module.module_path, + imports, + ast: module.ast, + signatures, + }); + } + + if errors.is_empty() { + Ok(TypedProject { + modules: typed_modules, + }) + } else { + Err(errors) + } +} + +struct TypecheckContext<'a> { + known_types: &'a HashSet, + generic_arity_registry: &'a GenericArityRegistry, + record_type_registry: &'a RecordTypeRegistry, + callable_registry: &'a HashMap>, + pattern_callable_names: &'a HashSet, + service_call_registry: &'a ServiceCallRegistry, + interface_registry: &'a InterfaceRegistry, + resource_type_registry: &'a ResourceTypeRegistry, + resource_capability_registry: &'a ResourceCapabilityRegistry, + allow_unresolved_references: bool, +} + +fn collect_signatures( + module: &ResolvedModule, + context: &TypecheckContext<'_>, + module_name: &str, + errors: &mut Vec, +) -> Vec { + let mut module_known_types = context.known_types.clone(); + for import in &module.ast.imports { + if let Some(bindings) = &import.node.bindings { + for binding in bindings { + module_known_types.insert(binding.clone()); + } + } + } + + let mut seen_items = HashSet::new(); + let mut signatures = Vec::new(); + let body_context = BodyInferenceContext { + record_type_registry: context.record_type_registry, + callable_registry: context.callable_registry, + pattern_callable_names: context.pattern_callable_names, + service_call_registry: context.service_call_registry, + interface_registry: context.interface_registry, + resource_type_registry: context.resource_type_registry, + resource_capability_registry: context.resource_capability_registry, + allow_unresolved_references: context.allow_unresolved_references, + }; + + for item in &module.ast.items { + match &item.node { + Item::TypeDef(def) => { + if !seen_items.insert(def.name.clone()) { + errors.push(TypeError::DuplicateDefinition { + module: module_name.to_string(), + name: def.name.clone(), + }); + } + signatures.push(TypedItemSignature::Type { + name: def.name.clone(), + }); + } + Item::FnDef(def) => { + record_duplicate_item_name(module_name, &def.name, &mut seen_items, errors); + let item_known_types = extend_known_types(&module_known_types, &def.type_params); + validate_params( + &def.name, + &def.params, + &item_known_types, + context.generic_arity_registry, + errors, + ); + validate_type_expr( + &def.return_type, + &item_known_types, + context.generic_arity_registry, + &format!("{}.return", def.name), + errors, + ); + let outputs = vec![TypedBinding { + name: "return".to_string(), + ty: type_expr_to_string(&def.return_type), + }]; + validate_callable_body( + &def.name, + &def.params, + ReturnContract::single(type_expr_to_string(&def.return_type)), + &[], + CallableBodyRef { + stmts: &def.body.stmts, + is_lossy: def.body.lossy, + }, + &body_context, + errors, + ); + signatures.push(TypedItemSignature::Fn(TypedCallableSignature { + name: def.name.clone(), + params: def + .params + .iter() + .map(|param| TypedBinding { + name: param.name.clone(), + ty: type_expr_to_string(¶m.ty), + }) + .collect(), + outputs, + })); + } + Item::FuncDef(def) => { + record_duplicate_item_name(module_name, &def.name, &mut seen_items, errors); + let item_known_types = extend_known_types(&module_known_types, &def.type_params); + validate_params( + &def.name, + &def.params, + &item_known_types, + context.generic_arity_registry, + errors, + ); + validate_outputs( + &def.name, + &def.outputs, + &item_known_types, + context.generic_arity_registry, + errors, + ); + validate_uses_clauses( + &def.name, + &def.uses, + context.resource_type_registry, + context.allow_unresolved_references, + errors, + ); + validate_provides_clauses( + &def.name, + &def.provides, + context.resource_type_registry, + context.allow_unresolved_references, + errors, + ); + validate_use_provide_binding_conflicts(&def.name, &def.uses, &def.provides, errors); + validate_callable_body( + &def.name, + &def.params, + ReturnContract::record(field_signature_map(&def.outputs)), + &def.uses, + CallableBodyRef { + stmts: &def.body.stmts, + is_lossy: def.body.lossy, + }, + &body_context, + errors, + ); + signatures.push(TypedItemSignature::Func(TypedCallableSignature { + name: def.name.clone(), + params: def + .params + .iter() + .map(|param| TypedBinding { + name: param.name.clone(), + ty: type_expr_to_string(¶m.ty), + }) + .collect(), + outputs: def + .outputs + .iter() + .map(|field| TypedBinding { + name: field.name.clone(), + ty: type_expr_to_string(&field.ty), + }) + .collect(), + })); + } + Item::PatternDef(def) => { + record_duplicate_item_name(module_name, &def.name, &mut seen_items, errors); + let item_known_types = extend_known_types(&module_known_types, &def.type_params); + validate_params( + &def.name, + &def.params, + &item_known_types, + context.generic_arity_registry, + errors, + ); + validate_outputs( + &def.name, + &def.outputs, + &item_known_types, + context.generic_arity_registry, + errors, + ); + validate_uses_clauses( + &def.name, + &def.uses, + context.resource_type_registry, + context.allow_unresolved_references, + errors, + ); + validate_callable_body( + &def.name, + &def.params, + ReturnContract::record(field_signature_map(&def.outputs)), + &def.uses, + CallableBodyRef { + stmts: &def.body.stmts, + is_lossy: def.body.lossy, + }, + &body_context, + errors, + ); + signatures.push(TypedItemSignature::Pattern(TypedCallableSignature { + name: def.name.clone(), + params: def + .params + .iter() + .map(|param| TypedBinding { + name: param.name.clone(), + ty: type_expr_to_string(¶m.ty), + }) + .collect(), + outputs: def + .outputs + .iter() + .map(|field| TypedBinding { + name: field.name.clone(), + ty: type_expr_to_string(&field.ty), + }) + .collect(), + })); + } + Item::ServiceDef(def) => { + record_duplicate_item_name(module_name, &def.name, &mut seen_items, errors); + validate_service_interface_conformance(def, context.interface_registry, errors); + signatures.push(TypedItemSignature::Service { + name: def.name.clone(), + operations: def.operations.len(), + }); + } + Item::ResourceDef(def) => { + record_duplicate_item_name(module_name, &def.name, &mut seen_items, errors); + validate_resource_interface_conformance(def, context.interface_registry, errors); + signatures.push(TypedItemSignature::Resource { + name: def.name.clone(), + implements: def.implements.clone(), + }); + } + Item::InterfaceDef(def) => { + record_duplicate_item_name(module_name, &def.name, &mut seen_items, errors); + signatures.push(TypedItemSignature::Interface { + name: def.name.clone(), + capabilities: def.capabilities.len(), + }); + } + Item::PipelineDef(def) => { + record_duplicate_item_name(module_name, &def.name, &mut seen_items, errors); + signatures.push(TypedItemSignature::Pipeline { + name: def.name.clone(), + stages: def.stages.len(), + }); + } + } + } + + signatures +} + +fn extend_known_types(base: &HashSet, additional: &[String]) -> HashSet { + let mut known = base.clone(); + known.extend(additional.iter().cloned()); + known +} + +fn collect_known_types(modules: &[ResolvedModule]) -> HashSet { + let mut known = builtin_type_names(); + for module in modules { + let module_prefix = module.module_path.join("."); + for item in &module.ast.items { + match &item.node { + Item::TypeDef(def) => { + known.insert(def.name.clone()); + known.insert(format!("{module_prefix}.{}", def.name)); + } + Item::ResourceDef(def) => { + let config_name = format!("{}.Config", def.name); + known.insert(config_name.clone()); + known.insert(format!("{module_prefix}.{config_name}")); + } + _ => {} + } + } + } + known +} + +fn collect_generic_arities(modules: &[ResolvedModule]) -> GenericArityRegistry { + let mut registry = GenericArityRegistry::default(); + for (name, arity) in builtin_type_arities() { + registry.full.insert(name.clone(), arity); + registry.short.insert(name, Some(arity)); + } + + for module in modules { + let module_prefix = module.module_path.join("."); + for item in &module.ast.items { + let (name, arity) = match &item.node { + Item::TypeDef(def) => (&def.name, def.params.len()), + Item::InterfaceDef(def) => (&def.name, def.type_params.len()), + Item::ResourceDef(def) => { + let name = format!("{}.Config", def.name); + let full_name = format!("{module_prefix}.{name}"); + registry.full.insert(full_name, 0); + registry + .short + .entry(name) + .and_modify(|existing| { + if let Some(current) = existing { + if *current != 0 { + *existing = None; + } + } + }) + .or_insert(Some(0)); + continue; + } + _ => continue, + }; + let full_name = format!("{module_prefix}.{name}"); + registry.full.insert(full_name.clone(), arity); + registry + .short + .entry(name.clone()) + .and_modify(|existing| { + if let Some(current) = existing { + if *current != arity { + *existing = None; + } + } + }) + .or_insert(Some(arity)); + } + } + registry +} + +fn collect_record_types(modules: &[ResolvedModule]) -> RecordTypeRegistry { + let mut registry = RecordTypeRegistry::default(); + for module in modules { + let module_prefix = module.module_path.join("."); + for item in &module.ast.items { + match &item.node { + Item::TypeDef(def) => { + let daglang_syntax::ast::TypeBody::Record(fields) = &def.body else { + continue; + }; + let signature = field_signature_map(fields); + let full_name = format!("{module_prefix}.{}", def.name); + registry.full.insert(full_name.clone(), signature.clone()); + registry + .full + .entry(def.name.clone()) + .or_insert(signature); + registry + .short + .entry(def.name.clone()) + .and_modify(|existing| { + if let Some(current) = existing { + if current != &full_name { + *existing = None; + } + } + }) + .or_insert(Some(full_name)); + } + Item::ResourceDef(def) if !def.config.is_empty() => { + let signature = field_signature_map(&def.config); + let config_name = format!("{}.Config", def.name); + let full_name = format!("{module_prefix}.{config_name}"); + registry.full.insert(full_name.clone(), signature.clone()); + registry.full.insert(config_name.clone(), signature); + registry + .short + .entry(config_name) + .and_modify(|existing| { + if let Some(current) = existing { + if current != &full_name { + *existing = None; + } + } + }) + .or_insert(Some(full_name)); + } + _ => {} + } + } + } + registry +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CallableContract { + arity: usize, + params: HashSet, + output: ValueType, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ServiceCallContract { + arity: usize, + params: HashSet, + outputs: HashMap, +} + +#[derive(Debug, Clone, Default)] +struct ServiceCallRegistry { + by_key: HashMap>, +} + +#[derive(Debug, Clone)] +enum ServiceCallResolution { + Resolved(ServiceCallContract), + Ambiguous, + Missing, +} + +#[derive(Debug, Clone)] +enum InterfaceResolution { + Resolved(InterfaceContract), + Ambiguous, + Missing, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ResourceTypeResolution { + Resolved(String), + Ambiguous, + Missing, +} + +fn collect_unique_callables( + modules: &[ResolvedModule], +) -> HashMap> { + let mut callables = HashMap::>::new(); + for module in modules { + for item in &module.ast.items { + match &item.node { + Item::FnDef(def) => register_callable_contract( + &mut callables, + def.name.clone(), + CallableContract { + arity: required_param_arity(&def.params), + params: def.params.iter().map(|param| param.name.clone()).collect(), + output: ValueType::Named(type_expr_to_string(&def.return_type)), + }, + ), + Item::FuncDef(def) => register_callable_contract( + &mut callables, + def.name.clone(), + CallableContract { + arity: required_param_arity(&def.params), + params: def.params.iter().map(|param| param.name.clone()).collect(), + output: ValueType::Record(field_signature_map(&def.outputs)), + }, + ), + Item::PatternDef(def) => register_callable_contract( + &mut callables, + def.name.clone(), + CallableContract { + arity: required_param_arity(&def.params), + params: def.params.iter().map(|param| param.name.clone()).collect(), + output: ValueType::Record(field_signature_map(&def.outputs)), + }, + ), + Item::TypeDef(def) => { + if let TypeBody::Sum(variants) = &def.body { + for variant in variants { + register_callable_contract( + &mut callables, + variant.name.clone(), + CallableContract { + arity: required_field_arity(&variant.fields), + params: variant + .fields + .iter() + .map(|field| field.name.clone()) + .collect(), + output: ValueType::Named(def.name.clone()), + }, + ); + } + } + } + _ => {} + } + } + } + for (name, contract) in builtin_callable_contracts() { + callables.entry(name).or_insert(Some(contract)); + } + callables +} + +fn collect_pattern_callable_names(modules: &[ResolvedModule]) -> HashSet { + modules + .iter() + .flat_map(|module| module.ast.items.iter()) + .filter_map(|item| match &item.node { + Item::PatternDef(def) => Some(def.name.clone()), + _ => None, + }) + .collect() +} + +fn required_param_arity(params: &[Param]) -> usize { + params.iter().filter(|param| param.default.is_none()).count() +} + +fn required_field_arity(fields: &[Field]) -> usize { + fields.iter().filter(|field| field.default.is_none()).count() +} + +fn callable_contract_max_arity(contract: &CallableContract) -> usize { + contract.arity.max(contract.params.len()) +} + +fn service_contract_max_arity(contract: &ServiceCallContract) -> usize { + contract.arity.max(contract.params.len()) +} + +fn register_callable_contract( + callables: &mut HashMap>, + name: String, + contract: CallableContract, +) { + callables + .entry(name) + .and_modify(|existing| { + if existing.is_some() { + *existing = None; + } + }) + .or_insert(Some(contract)); +} + +fn builtin_callable_contracts() -> Vec<(String, CallableContract)> { + vec![ + ( + "map".to_string(), + CallableContract { + arity: 1, + params: HashSet::from(["f".to_string()]), + output: ValueType::Named("List".to_string()), + }, + ), + ( + "filter".to_string(), + CallableContract { + arity: 1, + params: HashSet::from(["predicate".to_string()]), + output: ValueType::Named("List".to_string()), + }, + ), + ( + "join".to_string(), + CallableContract { + arity: 1, + params: HashSet::from(["separator".to_string()]), + output: ValueType::Named("String".to_string()), + }, + ), + ( + "count".to_string(), + CallableContract { + arity: 0, + params: HashSet::new(), + output: ValueType::Named("Int".to_string()), + }, + ), + ( + "all".to_string(), + CallableContract { + arity: 1, + params: HashSet::from(["predicate".to_string()]), + output: ValueType::Named("Bool".to_string()), + }, + ), + ( + "sort_by".to_string(), + CallableContract { + arity: 1, + params: HashSet::from(["key_fn".to_string()]), + output: ValueType::Named("List".to_string()), + }, + ), + ( + "first".to_string(), + CallableContract { + arity: 0, + params: HashSet::new(), + output: ValueType::Named("Any".to_string()), + }, + ), + ( + "last".to_string(), + CallableContract { + arity: 0, + params: HashSet::new(), + output: ValueType::Named("Any".to_string()), + }, + ), + ( + "ends_with".to_string(), + CallableContract { + arity: 1, + params: HashSet::from(["suffix".to_string()]), + output: ValueType::Named("Bool".to_string()), + }, + ), + ( + "to_bytes".to_string(), + CallableContract { + arity: 0, + params: HashSet::from(["value".to_string()]), + output: ValueType::Named("Bytes".to_string()), + }, + ), + ( + "to_json".to_string(), + CallableContract { + arity: 0, + params: HashSet::from(["value".to_string()]), + output: ValueType::Named("Json".to_string()), + }, + ), + ( + "hash".to_string(), + CallableContract { + arity: 0, + params: HashSet::from(["value".to_string()]), + output: ValueType::Named("String".to_string()), + }, + ), + ( + "eq".to_string(), + CallableContract { + arity: 2, + params: HashSet::from(["a".to_string(), "b".to_string()]), + output: ValueType::Named("Bool".to_string()), + }, + ), + ( + "replace_section".to_string(), + CallableContract { + arity: 2, + params: HashSet::from(["section".to_string(), "replacement".to_string()]), + output: ValueType::Named("String".to_string()), + }, + ), + ( + "render_test_listings".to_string(), + CallableContract { + arity: 1, + params: HashSet::from(["sources".to_string()]), + output: ValueType::Named("String".to_string()), + }, + ), + ( + "render_graph_structure".to_string(), + CallableContract { + arity: 1, + params: HashSet::from(["sources".to_string()]), + output: ValueType::Named("String".to_string()), + }, + ), + ( + "render_source_artifacts".to_string(), + CallableContract { + arity: 1, + params: HashSet::from(["sources".to_string()]), + output: ValueType::Named("String".to_string()), + }, + ), + ( + "build_token".to_string(), + CallableContract { + arity: 5, + params: HashSet::from([ + "payload".to_string(), + "scheme".to_string(), + "header_name".to_string(), + "source_id".to_string(), + "required_scopes".to_string(), + ]), + output: ValueType::Named("AccessToken".to_string()), + }, + ), + ( + "generate".to_string(), + CallableContract { + arity: 0, + params: HashSet::new(), + output: ValueType::Named("String".to_string()), + }, + ), + ( + "now".to_string(), + CallableContract { + arity: 0, + params: HashSet::new(), + output: ValueType::Named("String".to_string()), + }, + ), + ( + "compute_topology_diff".to_string(), + CallableContract { + arity: 2, + params: HashSet::from(["current".to_string(), "base".to_string()]), + output: ValueType::Named("DagDiff".to_string()), + }, + ), + ( + "render_annotated_mermaid".to_string(), + CallableContract { + arity: 3, + params: HashSet::from([ + "diff".to_string(), + "topology".to_string(), + "title".to_string(), + ]), + output: ValueType::Named("String".to_string()), + }, + ), + ( + "detect_runtime".to_string(), + CallableContract { + arity: 0, + params: HashSet::new(), + output: ValueType::Named("CloudRuntime".to_string()), + }, + ), + ] +} + +fn collect_service_call_contracts(modules: &[ResolvedModule]) -> ServiceCallRegistry { + let mut registry = ServiceCallRegistry::default(); + for module in modules { + let module_name = module.module_path.join("."); + for item in &module.ast.items { + let Item::ServiceDef(service) = &item.node else { + continue; + }; + for operation in &service.operations { + let contract = ServiceCallContract { + arity: required_field_arity(&operation.inputs), + params: operation + .inputs + .iter() + .map(|field| field.name.clone()) + .collect(), + outputs: field_signature_map(&operation.outputs), + }; + let service_tail = service + .name + .rsplit('.') + .next() + .unwrap_or(service.name.as_str()); + let mut keys = HashSet::new(); + keys.insert(format!("{}.{}", service.name, operation.name)); + keys.insert(format!("{service_tail}.{}", operation.name)); + keys.insert(format!("{}.{}.{}", module_name, service.name, operation.name)); + for key in keys { + register_service_call_contract(&mut registry, key, contract.clone()); + } + } + } + } + registry +} + +fn register_service_call_contract( + registry: &mut ServiceCallRegistry, + key: String, + contract: ServiceCallContract, +) { + registry + .by_key + .entry(key) + .and_modify(|existing| *existing = None) + .or_insert_with(|| Some(contract)); +} + +#[derive(Debug, Clone, Default)] +struct InterfaceRegistry { + full: HashMap, + short: HashMap>, +} + +#[derive(Debug, Clone, Default)] +struct ResourceTypeRegistry { + full: HashSet, + short: HashMap>, +} + +#[derive(Debug, Clone, Default)] +struct ResourceCapabilityRegistry { + full: HashMap>, +} + +#[derive(Debug, Clone, Default)] +struct GenericArityRegistry { + full: HashMap, + short: HashMap>, +} + +#[derive(Debug, Clone, Default)] +struct RecordTypeRegistry { + full: HashMap>, + short: HashMap>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ValueType { + Named(String), + Record(HashMap), + Unknown, +} + +#[derive(Debug, Clone)] +enum ReturnContract { + Single { ty: String }, + Record { fields: HashMap }, +} + +impl ReturnContract { + fn single(ty: String) -> Self { + Self::Single { ty } + } + + fn record(fields: HashMap) -> Self { + Self::Record { fields } + } +} + +#[derive(Clone, Copy)] +struct BodyInferenceContext<'a> { + record_type_registry: &'a RecordTypeRegistry, + callable_registry: &'a HashMap>, + pattern_callable_names: &'a HashSet, + service_call_registry: &'a ServiceCallRegistry, + interface_registry: &'a InterfaceRegistry, + resource_type_registry: &'a ResourceTypeRegistry, + resource_capability_registry: &'a ResourceCapabilityRegistry, + allow_unresolved_references: bool, +} + +#[derive(Debug, Clone, Default)] +struct BoundServiceCallRegistry { + by_binding: HashMap, +} + +#[derive(Debug, Clone)] +enum BoundServiceCallBinding { + Resolved(HashMap), + Deferred, +} + +#[derive(Debug, Clone)] +enum BoundServiceCallResolution { + Resolved(ServiceCallContract), + MissingCapability, + Deferred, + NotBound, +} + +struct ExprInferenceContext<'a> { + record_type_registry: &'a RecordTypeRegistry, + callable_registry: &'a HashMap>, + service_call_registry: &'a ServiceCallRegistry, + bound_service_registry: &'a BoundServiceCallRegistry, + param_callable_contracts: &'a HashMap, +} + +struct CallableBodyRef<'a> { + stmts: &'a [Stmt], + is_lossy: bool, +} + +fn collect_interfaces(modules: &[ResolvedModule]) -> InterfaceRegistry { + let mut registry = InterfaceRegistry::default(); + for module in modules { + let module_name = module.module_path.join("."); + for item in &module.ast.items { + let Item::InterfaceDef(interface) = &item.node else { + continue; + }; + let mut capabilities = HashMap::::new(); + for capability in &interface.capabilities { + capabilities.insert( + capability.name.clone(), + CapabilityContract { + inputs: field_signature_map(&capability.inputs), + outputs: field_signature_map(&capability.outputs), + }, + ); + } + let contract = InterfaceContract { capabilities }; + let full_name = format!("{module_name}.{}", interface.name); + registry.full.insert(full_name, contract.clone()); + + registry + .short + .entry(interface.name.clone()) + .and_modify(|existing| { + if existing.is_some() { + *existing = None; + } + }) + .or_insert_with(|| Some(contract.clone())); + } + } + registry +} + +fn collect_resource_types(modules: &[ResolvedModule]) -> ResourceTypeRegistry { + let mut registry = ResourceTypeRegistry::default(); + for module in modules { + let module_name = module.module_path.join("."); + for item in &module.ast.items { + let name = match &item.node { + Item::InterfaceDef(interface) => interface.name.as_str(), + Item::ResourceDef(resource) => resource.name.as_str(), + _ => continue, + }; + let full = format!("{module_name}.{name}"); + registry.full.insert(full.clone()); + registry + .short + .entry(name.to_string()) + .and_modify(|existing| { + if existing.is_some() { + *existing = None; + } + }) + .or_insert_with(|| Some(full)); + } + } + registry +} + +fn collect_resource_capabilities(modules: &[ResolvedModule]) -> ResourceCapabilityRegistry { + let mut registry = ResourceCapabilityRegistry::default(); + for module in modules { + let module_name = module.module_path.join("."); + for item in &module.ast.items { + let Item::ResourceDef(resource) = &item.node else { + continue; + }; + let capabilities = resource + .capabilities + .iter() + .map(|capability| { + ( + capability.name.clone(), + CapabilityContract { + inputs: field_signature_map(&capability.inputs), + outputs: field_signature_map(&capability.outputs), + }, + ) + }) + .collect::>(); + registry + .full + .insert(format!("{module_name}.{}", resource.name), capabilities); + } + } + registry +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InterfaceContract { + capabilities: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CapabilityContract { + inputs: HashMap, + outputs: HashMap, +} + +fn builtin_type_names() -> HashSet { + HashSet::from([ + "Any".to_string(), + "Unit".to_string(), + "Bool".to_string(), + "Int".to_string(), + "Float".to_string(), + "String".to_string(), + "Bytes".to_string(), + "Secret".to_string(), + "Json".to_string(), + "Record".to_string(), + "List".to_string(), + "Map".to_string(), + "Option".to_string(), + "Result".to_string(), + "Queue".to_string(), + "Self".to_string(), + ]) +} + +fn builtin_type_arities() -> HashMap { + HashMap::from([ + ("Any".to_string(), 0), + ("Unit".to_string(), 0), + ("Bool".to_string(), 0), + ("Int".to_string(), 0), + ("Float".to_string(), 0), + ("String".to_string(), 0), + ("Bytes".to_string(), 0), + ("Secret".to_string(), 0), + ("Json".to_string(), 0), + ("Record".to_string(), 0), + ("List".to_string(), 1), + ("Map".to_string(), 2), + ("Option".to_string(), 1), + ("Result".to_string(), 2), + ("Queue".to_string(), 1), + ("Self".to_string(), 0), + ]) +} + +fn record_duplicate_item_name( + module_name: &str, + item_name: &str, + seen_items: &mut HashSet, + errors: &mut Vec, +) { + if !seen_items.insert(item_name.to_string()) { + errors.push(TypeError::DuplicateDefinition { + module: module_name.to_string(), + name: item_name.to_string(), + }); + } +} + +fn validate_params( + item_name: &str, + params: &[Param], + known_types: &HashSet, + generic_arity_registry: &GenericArityRegistry, + errors: &mut Vec, +) { + let mut seen = HashSet::new(); + for param in params { + if !seen.insert(param.name.clone()) { + errors.push(TypeError::DuplicateParameter { + item: item_name.to_string(), + param: param.name.clone(), + }); + } + validate_type_expr( + ¶m.ty, + known_types, + generic_arity_registry, + &format!("{}.{}", item_name, param.name), + errors, + ); + } +} + +fn validate_outputs( + item_name: &str, + outputs: &[Field], + known_types: &HashSet, + generic_arity_registry: &GenericArityRegistry, + errors: &mut Vec, +) { + let mut seen = HashSet::new(); + for output in outputs { + if !seen.insert(output.name.clone()) { + errors.push(TypeError::DuplicateOutputField { + item: item_name.to_string(), + field: output.name.clone(), + }); + } + validate_type_expr( + &output.ty, + known_types, + generic_arity_registry, + &format!("{}.{}", item_name, output.name), + errors, + ); + } +} + +fn validate_uses_clauses( + item_name: &str, + uses: &[UsesClause], + registry: &ResourceTypeRegistry, + allow_unresolved_references: bool, + errors: &mut Vec, +) { + let mut seen_bindings = HashSet::new(); + for usage in uses { + if !seen_bindings.insert(usage.binding.clone()) { + errors.push(TypeError::DuplicateUsesBinding { + item: item_name.to_string(), + binding: usage.binding.clone(), + }); + } + if !allow_unresolved_references { + let resource_type = resource_type_name(&usage.resource_type); + match resolve_resource_type_name(&resource_type, registry) { + ResourceTypeResolution::Resolved(_) => {} + ResourceTypeResolution::Ambiguous => { + errors.push(TypeError::AmbiguousUsedResourceType { + item: item_name.to_string(), + binding: usage.binding.clone(), + resource_type, + }); + } + ResourceTypeResolution::Missing => { + errors.push(TypeError::UnknownUsedResourceType { + item: item_name.to_string(), + binding: usage.binding.clone(), + resource_type, + }); + } + } + } + } +} + +fn validate_provides_clauses( + item_name: &str, + provides: &[ProvidesClause], + registry: &ResourceTypeRegistry, + allow_unresolved_references: bool, + errors: &mut Vec, +) { + let mut seen_bindings = HashSet::new(); + for provided in provides { + if !seen_bindings.insert(provided.binding.clone()) { + errors.push(TypeError::DuplicateProvidesBinding { + item: item_name.to_string(), + binding: provided.binding.clone(), + }); + } + if !allow_unresolved_references { + let resource_type = resource_type_name(&provided.resource_type); + match resolve_resource_type_name(&resource_type, registry) { + ResourceTypeResolution::Resolved(_) => {} + ResourceTypeResolution::Ambiguous => { + errors.push(TypeError::AmbiguousProvidedResourceType { + item: item_name.to_string(), + binding: provided.binding.clone(), + resource_type, + }); + } + ResourceTypeResolution::Missing => { + errors.push(TypeError::UnknownProvidedResourceType { + item: item_name.to_string(), + binding: provided.binding.clone(), + resource_type, + }); + } + } + } + } +} + +fn validate_use_provide_binding_conflicts( + item_name: &str, + uses: &[UsesClause], + provides: &[ProvidesClause], + errors: &mut Vec, +) { + let used_bindings = uses + .iter() + .map(|usage| usage.binding.as_str()) + .collect::>(); + for provided in provides { + if used_bindings.contains(provided.binding.as_str()) { + errors.push(TypeError::UseProvideBindingConflict { + item: item_name.to_string(), + binding: provided.binding.clone(), + }); + } + } +} + +fn collect_param_callable_contracts(params: &[Param]) -> HashMap { + params + .iter() + .filter_map(|param| { + parse_function_type_callable_contract(¶m.ty) + .map(|contract| (param.name.clone(), contract)) + }) + .collect() +} + +fn parse_function_type_callable_contract(ty: &TypeExpr) -> Option { + let raw = type_expr_to_string(ty); + let compact = raw.chars().filter(|ch| !ch.is_whitespace()).collect::(); + if !compact.starts_with("fn(") { + return None; + } + let close_paren = find_matching_paren(&compact, 2)?; + let args = &compact[3..close_paren]; + let output = compact + .get(close_paren + 1..)? + .strip_prefix("->") + .filter(|text| !text.is_empty())? + .to_string(); + Some(CallableContract { + arity: parse_function_type_arity(args), + params: HashSet::new(), + output: ValueType::Named(output), + }) +} + +fn find_matching_paren(text: &str, open_index: usize) -> Option { + let mut depth = 0usize; + for (index, ch) in text.char_indices().filter(|(idx, _)| *idx >= open_index) { + match ch { + '(' => depth += 1, + ')' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(index); + } + } + _ => {} + } + } + None +} + +fn parse_function_type_arity(args: &str) -> usize { + if args.is_empty() { + return 0; + } + let mut arity = 1usize; + let mut paren_depth = 0usize; + let mut angle_depth = 0usize; + let mut bracket_depth = 0usize; + let mut brace_depth = 0usize; + for ch in args.chars() { + match ch { + '(' => paren_depth += 1, + ')' => paren_depth = paren_depth.saturating_sub(1), + '<' => angle_depth += 1, + '>' => angle_depth = angle_depth.saturating_sub(1), + '[' => bracket_depth += 1, + ']' => bracket_depth = bracket_depth.saturating_sub(1), + '{' => brace_depth += 1, + '}' => brace_depth = brace_depth.saturating_sub(1), + ',' if paren_depth == 0 + && angle_depth == 0 + && bracket_depth == 0 + && brace_depth == 0 => + { + arity += 1; + } + _ => {} + } + } + arity +} + +fn validate_callable_body( + caller: &str, + params: &[Param], + return_contract: ReturnContract, + uses: &[UsesClause], + body: CallableBodyRef<'_>, + body_context: &BodyInferenceContext<'_>, + errors: &mut Vec, +) { + let bound_service_registry = build_bound_service_call_registry(uses, body_context); + let param_callable_contracts = collect_param_callable_contracts(params); + let infer_context = ExprInferenceContext { + record_type_registry: body_context.record_type_registry, + callable_registry: body_context.callable_registry, + service_call_registry: body_context.service_call_registry, + bound_service_registry: &bound_service_registry, + param_callable_contracts: ¶m_callable_contracts, + }; + let mut calls = Vec::new(); + collect_calls_from_stmts(body.stmts, &mut calls); + for call in calls { + let contract = match param_callable_contracts.get(&call.callee) { + Some(contract) => contract, + None => match body_context.callable_registry.get(&call.callee) { + Some(Some(contract)) => contract, + Some(None) => { + if !body_context.allow_unresolved_references { + errors.push(TypeError::AmbiguousCallTarget { + caller: caller.to_string(), + callee: call.callee.clone(), + }); + } + continue; + } + None => { + if !body_context.allow_unresolved_references { + errors.push(TypeError::UnresolvedCallTarget { + caller: caller.to_string(), + callee: call.callee.clone(), + }); + } + continue; + } + }, + }; + let is_pattern_callable = body_context.pattern_callable_names.contains(&call.callee); + if !is_pattern_callable { + let max_arity = callable_contract_max_arity(contract); + if call.arg_count < contract.arity || call.arg_count > max_arity { + let expected = if call.arg_count < contract.arity { + contract.arity + } else { + max_arity + }; + errors.push(TypeError::CallArityMismatch { + caller: caller.to_string(), + callee: call.callee.clone(), + expected, + got: call.arg_count, + }); + } + } + let mut seen_named = HashSet::new(); + for named in call.named_args { + if !seen_named.insert(named.clone()) { + errors.push(TypeError::DuplicateCallArgument { + caller: caller.to_string(), + callee: call.callee.clone(), + argument: named, + }); + continue; + } + if !is_pattern_callable && !contract.params.contains(&named) { + errors.push(TypeError::UnknownCallArgument { + caller: caller.to_string(), + callee: call.callee.clone(), + argument: named, + }); + } + } + } + + let mut service_calls = Vec::new(); + collect_service_calls_from_stmts(body.stmts, &mut service_calls); + for call in service_calls { + let service_call_name = call.path.join("."); + let contract = match resolve_service_call_contract(&call.path, body_context.service_call_registry) + { + ServiceCallResolution::Resolved(contract) => Some(contract), + ServiceCallResolution::Ambiguous => { + if !body_context.allow_unresolved_references { + errors.push(TypeError::AmbiguousServiceCall { + caller: caller.to_string(), + service_call: service_call_name.clone(), + }); + } + None + } + ServiceCallResolution::Missing => { + match resolve_bound_service_call_contract(&call.path, &bound_service_registry) { + BoundServiceCallResolution::Resolved(contract) => Some(contract), + BoundServiceCallResolution::MissingCapability + | BoundServiceCallResolution::NotBound => { + if !body_context.allow_unresolved_references { + errors.push(TypeError::UnresolvedServiceCall { + caller: caller.to_string(), + service_call: service_call_name.clone(), + }); + } + None + } + BoundServiceCallResolution::Deferred => None, + } + } + }; + let Some(contract) = contract else { + continue; + }; + let max_arity = service_contract_max_arity(&contract); + if call.arg_count < contract.arity || call.arg_count > max_arity { + let expected = if call.arg_count < contract.arity { + contract.arity + } else { + max_arity + }; + errors.push(TypeError::ServiceCallArityMismatch { + caller: caller.to_string(), + service_call: service_call_name.clone(), + expected, + got: call.arg_count, + }); + } + let mut seen_named = HashSet::new(); + for named in call.named_args { + if !seen_named.insert(named.clone()) { + errors.push(TypeError::DuplicateServiceCallArgument { + caller: caller.to_string(), + service_call: service_call_name.clone(), + argument: named, + }); + continue; + } + if !contract.params.contains(&named) { + errors.push(TypeError::UnknownServiceCallArgument { + caller: caller.to_string(), + service_call: service_call_name.clone(), + argument: named, + }); + } + } + } + + let mut local_bindings = params + .iter() + .map(|param| { + ( + param.name.clone(), + ValueType::Named(type_expr_to_string(¶m.ty)), + ) + }) + .collect::>(); + let mut saw_explicit_return = false; + let mut trailing_expr_type = None; + let mut trailing_expr = None; + for stmt in body.stmts { + match stmt { + Stmt::Let(name, expr) | Stmt::Assign(name, expr) => { + let inferred = infer_expr_type( + expr, + &local_bindings, + &infer_context, + errors, + ); + local_bindings.insert(name.clone(), inferred); + trailing_expr_type = None; + trailing_expr = None; + } + Stmt::Expr(expr) => { + trailing_expr = Some(expr); + trailing_expr_type = Some(infer_expr_type( + expr, + &local_bindings, + &infer_context, + errors, + )); + } + Stmt::Return(fields) => { + saw_explicit_return = true; + trailing_expr_type = None; + trailing_expr = None; + validate_return_stmt( + caller, + &return_contract, + fields, + &local_bindings, + &infer_context, + errors, + ); + } + } + } + if !body.is_lossy && !saw_explicit_return { + if let ReturnContract::Single { ty } = &return_contract { + let inferred = match trailing_expr { + Some(expr) => infer_expr_type_for_expected_named_record( + expr, + ty, + &local_bindings, + &infer_context, + errors, + ), + None => trailing_expr_type + .unwrap_or_else(|| ValueType::Named("Unit".to_string())), + }; + push_type_mismatch_if_needed(ty, &inferred, errors); + } + } +} + +fn validate_return_stmt( + caller: &str, + return_contract: &ReturnContract, + fields: &[(String, Expr)], + local_bindings: &HashMap, + infer_context: &ExprInferenceContext<'_>, + errors: &mut Vec, +) { + match return_contract { + ReturnContract::Single { ty } => { + if fields.len() != 1 { + errors.push(TypeError::TypeMismatch { + expected: ty.clone(), + got: "Record".to_string(), + }); + return; + } + let inferred = infer_expr_type_for_expected_named_record( + &fields[0].1, + ty, + local_bindings, + infer_context, + errors, + ); + push_type_mismatch_if_needed(ty, &inferred, errors); + } + ReturnContract::Record { fields: expected } => { + for (field, expr) in fields { + let Some(expected_ty) = expected.get(field) else { + errors.push(TypeError::NoSuchField { + ty: format!("{caller}.outputs"), + field: field.clone(), + }); + continue; + }; + let inferred = infer_expr_type( + expr, + local_bindings, + infer_context, + errors, + ); + push_type_mismatch_if_needed(expected_ty, &inferred, errors); + } + } + } +} + +fn infer_expr_type_for_expected_named_record( + expr: &Expr, + expected_type: &str, + local_bindings: &HashMap, + infer_context: &ExprInferenceContext<'_>, + errors: &mut Vec, +) -> ValueType { + let Expr::Record(None, fields) = expr else { + return infer_expr_type(expr, local_bindings, infer_context, errors); + }; + + let Some(expected_fields) = resolve_record_fields(expected_type, infer_context.record_type_registry) else { + return infer_expr_type(expr, local_bindings, infer_context, errors); + }; + + let mut inferred_fields = HashMap::new(); + let mut compatible = true; + for (name, value_expr) in fields { + let inferred = infer_expr_type(value_expr, local_bindings, infer_context, errors); + let inferred_name = inferred + .display_name() + .unwrap_or_else(|| "Any".to_string()); + inferred_fields.insert(name.clone(), inferred_name.clone()); + let Some(expected_field_ty) = expected_fields.get(name) else { + errors.push(TypeError::NoSuchField { + ty: expected_type.to_string(), + field: name.clone(), + }); + compatible = false; + continue; + }; + if !types_match(expected_field_ty, &inferred_name) { + errors.push(TypeError::TypeMismatch { + expected: expected_field_ty.clone(), + got: inferred_name, + }); + compatible = false; + } + } + + if compatible { + ValueType::Named(expected_type.to_string()) + } else { + ValueType::Record(inferred_fields) + } +} + +fn infer_expr_type( + expr: &Expr, + local_bindings: &HashMap, + infer_context: &ExprInferenceContext<'_>, + errors: &mut Vec, +) -> ValueType { + match expr { + Expr::Literal(literal) => match literal { + daglang_syntax::ast::Literal::Int(_) => ValueType::Named("Int".to_string()), + daglang_syntax::ast::Literal::Float(_) => ValueType::Named("Float".to_string()), + daglang_syntax::ast::Literal::String(_) => ValueType::Named("String".to_string()), + daglang_syntax::ast::Literal::Bool(_) => ValueType::Named("Bool".to_string()), + daglang_syntax::ast::Literal::None => ValueType::Named("Unit".to_string()), + }, + Expr::Ident(name) => local_bindings + .get(name) + .cloned() + .or_else(|| { + infer_context + .param_callable_contracts + .get(name) + .filter(|contract| callable_contract_max_arity(contract) == 0) + .map(|contract| contract.output.clone()) + }) + .or_else(|| { + infer_context + .callable_registry + .get(name) + .and_then(|entry| entry.as_ref()) + .filter(|contract| callable_contract_max_arity(contract) == 0) + .map(|contract| contract.output.clone()) + }) + .unwrap_or(ValueType::Unknown), + Expr::FieldAccess(base, field) => { + let base_type = infer_expr_type(base, local_bindings, infer_context, errors); + match base_type { + ValueType::Record(fields) => match fields.get(field) { + Some(ty) => ValueType::Named(ty.clone()), + None => { + errors.push(TypeError::NoSuchField { + ty: "Record".to_string(), + field: field.clone(), + }); + ValueType::Unknown + } + }, + ValueType::Named(name) => { + match resolve_record_fields(&name, infer_context.record_type_registry) { + Some(fields) => match fields.get(field) { + Some(ty) => ValueType::Named(ty.clone()), + None => { + errors.push(TypeError::NoSuchField { + ty: name, + field: field.clone(), + }); + ValueType::Unknown + } + }, + None => ValueType::Unknown, + } + } + ValueType::Unknown => ValueType::Unknown, + } + } + Expr::Call(name, args) => { + for (_, arg) in args { + infer_expr_type(arg, local_bindings, infer_context, errors); + } + if let Some(contract) = infer_context.param_callable_contracts.get(name) { + contract.output.clone() + } else { + infer_context + .callable_registry + .get(name) + .and_then(|entry| entry.as_ref()) + .map(|contract| contract.output.clone()) + .unwrap_or(ValueType::Unknown) + } + } + Expr::ServiceCall(path, args) => { + for (_, arg) in args { + infer_expr_type(arg, local_bindings, infer_context, errors); + } + match resolve_service_call_contract(path, infer_context.service_call_registry) { + ServiceCallResolution::Resolved(contract) => ValueType::Record(contract.outputs), + ServiceCallResolution::Ambiguous => ValueType::Unknown, + ServiceCallResolution::Missing => { + match resolve_bound_service_call_contract( + path, + infer_context.bound_service_registry, + ) { + BoundServiceCallResolution::Resolved(contract) => { + ValueType::Record(contract.outputs) + } + BoundServiceCallResolution::MissingCapability + | BoundServiceCallResolution::Deferred + | BoundServiceCallResolution::NotBound => ValueType::Unknown, + } + } + } + } + Expr::BinOp(lhs, op, rhs) => { + let lhs_ty = infer_expr_type(lhs, local_bindings, infer_context, errors); + let rhs_ty = infer_expr_type(rhs, local_bindings, infer_context, errors); + match op { + daglang_syntax::ast::BinOp::Eq + | daglang_syntax::ast::BinOp::Ne + | daglang_syntax::ast::BinOp::Lt + | daglang_syntax::ast::BinOp::Gt + | daglang_syntax::ast::BinOp::Le + | daglang_syntax::ast::BinOp::Ge + | daglang_syntax::ast::BinOp::And + | daglang_syntax::ast::BinOp::Or => ValueType::Named("Bool".to_string()), + _ => match (lhs_ty, rhs_ty) { + (ValueType::Named(lhs), ValueType::Named(rhs)) + if canonical_type_name(&lhs) == canonical_type_name(&rhs) => + { + ValueType::Named(lhs) + } + _ => ValueType::Unknown, + }, + } + } + Expr::UnaryOp(op, inner) => { + let inner_ty = infer_expr_type(inner, local_bindings, infer_context, errors); + match op { + daglang_syntax::ast::UnaryOp::Not => ValueType::Named("Bool".to_string()), + daglang_syntax::ast::UnaryOp::Neg => inner_ty, + } + } + Expr::StringInterp(parts) => { + for part in parts { + if let daglang_syntax::ast::StringPart::Expr(inner) = part { + infer_expr_type(inner, local_bindings, infer_context, errors); + } + } + ValueType::Named("String".to_string()) + } + Expr::Record(type_name, fields) => { + for (_, value) in fields { + infer_expr_type(value, local_bindings, infer_context, errors); + } + if let Some(name) = type_name { + ValueType::Named(name.clone()) + } else { + ValueType::Record( + fields + .iter() + .map(|(name, expr)| { + ( + name.clone(), + infer_expr_type(expr, local_bindings, infer_context, errors) + .display_name() + .unwrap_or_else(|| "Any".to_string()), + ) + }) + .collect(), + ) + } + } + Expr::Match(scrutinee, arms) => { + infer_expr_type(scrutinee, local_bindings, infer_context, errors); + for arm in arms { + if let Some(guard) = &arm.guard { + infer_expr_type(guard, local_bindings, infer_context, errors); + } + infer_expr_type(&arm.body, local_bindings, infer_context, errors); + } + ValueType::Unknown + } + Expr::If(cond, then_expr, else_expr) => { + infer_expr_type(cond, local_bindings, infer_context, errors); + let then_ty = infer_expr_type(then_expr, local_bindings, infer_context, errors); + let else_ty = else_expr.as_ref().map(|otherwise| { + infer_expr_type(otherwise, local_bindings, infer_context, errors) + }); + match else_ty { + Some(otherwise) + if then_ty.display_name().is_some() + && then_ty.display_name() == otherwise.display_name() => + { + then_ty + } + _ => ValueType::Unknown, + } + } + Expr::For(_, iterable, body) => { + infer_expr_type(iterable, local_bindings, infer_context, errors); + infer_expr_type(body, local_bindings, infer_context, errors); + ValueType::Unknown + } + Expr::Pipe(lhs, rhs) => { + infer_expr_type(lhs, local_bindings, infer_context, errors); + infer_expr_type(rhs, local_bindings, infer_context, errors) + } + Expr::Lambda(_, body) => infer_expr_type(body, local_bindings, infer_context, errors), + Expr::List(items) => { + for item in items { + infer_expr_type(item, local_bindings, infer_context, errors); + } + ValueType::Named("List".to_string()) + } + Expr::Map(entries) => { + for (key, value) in entries { + infer_expr_type(key, local_bindings, infer_context, errors); + infer_expr_type(value, local_bindings, infer_context, errors); + } + ValueType::Named("Map".to_string()) + } + Expr::Guarded(inner, guard) => { + infer_expr_type(inner, local_bindings, infer_context, errors); + infer_expr_type(guard, local_bindings, infer_context, errors); + ValueType::Unknown + } + Expr::After(inner, _) => infer_expr_type(inner, local_bindings, infer_context, errors), + Expr::Return(fields) => ValueType::Record( + fields + .iter() + .map(|(name, expr)| { + ( + name.clone(), + infer_expr_type(expr, local_bindings, infer_context, errors) + .display_name() + .unwrap_or_else(|| "Any".to_string()), + ) + }) + .collect(), + ), + } +} + +impl ValueType { + fn display_name(&self) -> Option { + match self { + Self::Named(name) => Some(name.clone()), + Self::Record(_) => Some("Record".to_string()), + Self::Unknown => None, + } + } +} + +fn push_type_mismatch_if_needed(expected: &str, inferred: &ValueType, errors: &mut Vec) { + let Some(got) = inferred.display_name() else { + return; + }; + if !types_match(expected, &got) { + errors.push(TypeError::TypeMismatch { + expected: expected.to_string(), + got, + }); + } +} + +fn types_match(expected: &str, got: &str) -> bool { + if expected == got { + return true; + } + let expected_canonical = canonical_type_name(expected); + let got_canonical = canonical_type_name(got); + expected_canonical == got_canonical + || expected_canonical.rsplit('.').next() == got_canonical.rsplit('.').next() +} + +fn resolve_record_fields( + ty: &str, + registry: &RecordTypeRegistry, +) -> Option> { + let canonical = canonical_type_name(ty); + if let Some(fields) = registry.full.get(&canonical) { + return Some(fields.clone()); + } + let short = canonical.rsplit('.').next().unwrap_or(canonical.as_str()); + let Some(Some(full_name)) = registry.short.get(short) else { + return None; + }; + registry.full.get(full_name).cloned() +} + +fn validate_resource_interface_conformance( + resource: &daglang_syntax::ast::ResourceDef, + interface_registry: &InterfaceRegistry, + errors: &mut Vec, +) { + let Some(implemented) = resource.implements.as_deref() else { + return; + }; + let interface_contract = match resolve_interface_contract(implemented, interface_registry) { + InterfaceResolution::Resolved(contract) => contract, + InterfaceResolution::Ambiguous => { + errors.push(TypeError::AmbiguousInterface { + implementor: resource.name.clone(), + interface: implemented.to_string(), + }); + return; + } + InterfaceResolution::Missing => { + errors.push(TypeError::UnresolvedInterface { + implementor: resource.name.clone(), + interface: implemented.to_string(), + }); + return; + } + }; + let provided_capabilities = resource + .capabilities + .iter() + .map(|capability| { + ( + capability.name.clone(), + CapabilityContract { + inputs: field_signature_map(&capability.inputs), + outputs: field_signature_map(&capability.outputs), + }, + ) + }) + .collect::>(); + let interface_name = canonical_interface_name(implemented); + for (capability_name, required_contract) in interface_contract.capabilities { + let Some(provided_contract) = provided_capabilities.get(&capability_name) else { + errors.push(TypeError::MissingCapability { + resource: resource.name.clone(), + interface: interface_name.clone(), + capability: capability_name, + }); + continue; + }; + validate_capability_contract( + &resource.name, + &interface_name, + &capability_name, + provided_contract, + &required_contract, + errors, + ); + } +} + +fn validate_capability_contract( + implementor: &str, + interface: &str, + capability: &str, + provided: &CapabilityContract, + required: &CapabilityContract, + errors: &mut Vec, +) { + validate_signature_map( + implementor, + interface, + capability, + "input", + &provided.inputs, + &required.inputs, + errors, + ); + validate_signature_map( + implementor, + interface, + capability, + "output", + &provided.outputs, + &required.outputs, + errors, + ); +} + +fn validate_signature_map( + implementor: &str, + interface: &str, + capability: &str, + direction: &str, + provided: &HashMap, + required: &HashMap, + errors: &mut Vec, +) { + for (field, expected_ty) in required { + let Some(provided_ty) = provided.get(field) else { + errors.push(TypeError::InterfaceSignatureMismatch { + implementor: implementor.to_string(), + interface: interface.to_string(), + capability: capability.to_string(), + detail: format!("missing {direction} field `{field}`"), + }); + continue; + }; + if provided_ty != expected_ty { + errors.push(TypeError::InterfaceSignatureMismatch { + implementor: implementor.to_string(), + interface: interface.to_string(), + capability: capability.to_string(), + detail: format!( + "{direction} field `{field}` expected `{expected_ty}` but found `{provided_ty}`" + ), + }); + } + } +} + +fn validate_service_interface_conformance( + service: &daglang_syntax::ast::ServiceDef, + interface_registry: &InterfaceRegistry, + errors: &mut Vec, +) { + let Some(implemented) = service.implements.as_deref() else { + return; + }; + let interface_contract = match resolve_interface_contract(implemented, interface_registry) { + InterfaceResolution::Resolved(contract) => contract, + InterfaceResolution::Ambiguous => { + errors.push(TypeError::AmbiguousInterface { + implementor: service.name.clone(), + interface: implemented.to_string(), + }); + return; + } + InterfaceResolution::Missing => { + errors.push(TypeError::UnresolvedInterface { + implementor: service.name.clone(), + interface: implemented.to_string(), + }); + return; + } + }; + let provided_operations = service + .operations + .iter() + .map(|operation| { + ( + operation.name.clone(), + CapabilityContract { + inputs: field_signature_map(&operation.inputs), + outputs: field_signature_map(&operation.outputs), + }, + ) + }) + .collect::>(); + let interface_name = canonical_interface_name(implemented); + for (capability_name, required_contract) in interface_contract.capabilities { + let Some(provided_contract) = provided_operations.get(&capability_name) else { + errors.push(TypeError::MissingOperation { + service: service.name.clone(), + interface: interface_name.clone(), + operation: capability_name, + }); + continue; + }; + validate_capability_contract( + &service.name, + &interface_name, + &capability_name, + provided_contract, + &required_contract, + errors, + ); + } +} + +fn field_signature_map(fields: &[Field]) -> HashMap { + fields + .iter() + .map(|field| (field.name.clone(), type_expr_to_string(&field.ty))) + .collect() +} + +fn resolve_interface_contract( + implemented: &str, + registry: &InterfaceRegistry, +) -> InterfaceResolution { + let canonical = canonical_type_name(implemented); + if let Some(contract) = registry.full.get(&canonical) { + return InterfaceResolution::Resolved(contract.clone()); + } + let short = canonical.rsplit('.').next().unwrap_or(canonical.as_str()); + match registry.short.get(short) { + Some(Some(contract)) => InterfaceResolution::Resolved(contract.clone()), + Some(None) => InterfaceResolution::Ambiguous, + None => InterfaceResolution::Missing, + } +} + +fn canonical_interface_name(name: &str) -> String { + canonical_type_name(name) +} + +fn resolve_resource_type_name( + resource_type: &str, + registry: &ResourceTypeRegistry, +) -> ResourceTypeResolution { + if registry.full.contains(resource_type) { + return ResourceTypeResolution::Resolved(resource_type.to_string()); + } + let short = resource_type.rsplit('.').next().unwrap_or(resource_type); + match registry.short.get(short) { + Some(Some(resolved)) => ResourceTypeResolution::Resolved(resolved.clone()), + Some(None) => ResourceTypeResolution::Ambiguous, + None => ResourceTypeResolution::Missing, + } +} + +fn resolve_service_call_contract( + call_path: &[String], + registry: &ServiceCallRegistry, +) -> ServiceCallResolution { + if call_path.len() < 2 { + return ServiceCallResolution::Missing; + } + let Some(operation) = call_path.last() else { + return ServiceCallResolution::Missing; + }; + let service_name = call_path[..call_path.len() - 1].join("."); + let short_service = call_path[call_path.len() - 2].clone(); + let keys = [ + format!("{service_name}.{operation}"), + format!("{short_service}.{operation}"), + call_path.join("."), + ]; + let mut saw_ambiguous = false; + for key in keys { + if let Some(entry) = registry.by_key.get(&key) { + match entry { + Some(contract) => return ServiceCallResolution::Resolved(contract.clone()), + None => saw_ambiguous = true, + } + } + } + if saw_ambiguous { + ServiceCallResolution::Ambiguous + } else { + ServiceCallResolution::Missing + } +} + +fn build_bound_service_call_registry( + uses: &[UsesClause], + body_context: &BodyInferenceContext<'_>, +) -> BoundServiceCallRegistry { + let mut registry = BoundServiceCallRegistry::default(); + for usage in uses { + let resource_type = resource_type_name(&usage.resource_type); + let binding = match resolve_resource_type_name(&resource_type, body_context.resource_type_registry) { + ResourceTypeResolution::Resolved(resolved_type) => { + if let Some(interface_contract) = + body_context.interface_registry.full.get(&resolved_type) + { + let capabilities = interface_contract + .capabilities + .iter() + .map(|(name, contract)| { + ( + name.clone(), + ServiceCallContract { + arity: contract.inputs.len(), + params: contract.inputs.keys().cloned().collect(), + outputs: contract.outputs.clone(), + }, + ) + }) + .collect::>(); + BoundServiceCallBinding::Resolved(capabilities) + } else if let Some(resource_capabilities) = + body_context.resource_capability_registry.full.get(&resolved_type) + { + let capabilities = resource_capabilities + .iter() + .map(|(name, contract)| { + ( + name.clone(), + ServiceCallContract { + arity: contract.inputs.len(), + params: contract.inputs.keys().cloned().collect(), + outputs: contract.outputs.clone(), + }, + ) + }) + .collect::>(); + BoundServiceCallBinding::Resolved(capabilities) + } else { + BoundServiceCallBinding::Deferred + } + } + ResourceTypeResolution::Ambiguous | ResourceTypeResolution::Missing => { + BoundServiceCallBinding::Deferred + } + }; + registry.by_binding.insert(usage.binding.clone(), binding); + } + registry +} + +fn resolve_bound_service_call_contract( + call_path: &[String], + registry: &BoundServiceCallRegistry, +) -> BoundServiceCallResolution { + if call_path.len() != 2 { + return BoundServiceCallResolution::NotBound; + } + let binding = &call_path[0]; + let capability = &call_path[1]; + let Some(binding_contracts) = registry.by_binding.get(binding) else { + return BoundServiceCallResolution::NotBound; + }; + match binding_contracts { + BoundServiceCallBinding::Resolved(capabilities) => capabilities + .get(capability) + .cloned() + .map(BoundServiceCallResolution::Resolved) + .unwrap_or(BoundServiceCallResolution::MissingCapability), + BoundServiceCallBinding::Deferred => BoundServiceCallResolution::Deferred, + } +} + +fn canonical_type_name(name: &str) -> String { + name.split('<').next().unwrap_or(name).trim().to_string() +} + +fn canonical_resource_type_name(name: &str) -> String { + let base_without_config = name.split('(').next().unwrap_or(name).trim(); + let base_without_annotations = base_without_config + .split_whitespace() + .next() + .unwrap_or(base_without_config); + canonical_type_name(base_without_annotations) +} + +fn resource_type_name(resource_type: &TypeExpr) -> String { + match resource_type { + TypeExpr::Named(name) | TypeExpr::Generic(name, _) => canonical_resource_type_name(name), + TypeExpr::Optional(inner) | TypeExpr::Annotated(inner, _) => resource_type_name(inner), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BodyCall { + callee: String, + arg_count: usize, + named_args: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BodyServiceCall { + path: Vec, + arg_count: usize, + named_args: Vec, +} + +fn collect_calls_from_stmts(stmts: &[Stmt], calls: &mut Vec) { + for stmt in stmts { + match stmt { + Stmt::Let(_, expr) | Stmt::Assign(_, expr) | Stmt::Expr(expr) => { + collect_calls_from_expr(expr, calls); + } + Stmt::Return(fields) => { + for (_, expr) in fields { + collect_calls_from_expr(expr, calls); + } + } + } + } +} + +fn collect_calls_from_expr(expr: &Expr, calls: &mut Vec) { + match expr { + Expr::Call(name, args) => { + if should_validate_call_name(name) { + calls.push(BodyCall { + callee: name.clone(), + arg_count: args.len(), + named_args: args + .iter() + .filter_map(|(name, _)| name.clone()) + .collect::>(), + }); + } + for (_, arg) in args { + collect_calls_from_expr(arg, calls); + } + } + Expr::ServiceCall(_, args) => { + for (_, arg) in args { + collect_calls_from_expr(arg, calls); + } + } + Expr::FieldAccess(base, _) => collect_calls_from_expr(base, calls), + Expr::BinOp(lhs, _, rhs) => { + collect_calls_from_expr(lhs, calls); + collect_calls_from_expr(rhs, calls); + } + Expr::UnaryOp(_, inner) => collect_calls_from_expr(inner, calls), + Expr::StringInterp(parts) => { + for part in parts { + if let daglang_syntax::ast::StringPart::Expr(inner) = part { + collect_calls_from_expr(inner, calls); + } + } + } + Expr::Record(_, fields) => { + for (_, value) in fields { + collect_calls_from_expr(value, calls); + } + } + Expr::Match(scrutinee, arms) => { + collect_calls_from_expr(scrutinee, calls); + for arm in arms { + if let Some(guard) = &arm.guard { + collect_calls_from_expr(guard, calls); + } + collect_calls_from_expr(&arm.body, calls); + } + } + Expr::If(cond, then_expr, else_expr) => { + collect_calls_from_expr(cond, calls); + collect_calls_from_expr(then_expr, calls); + if let Some(otherwise) = else_expr { + collect_calls_from_expr(otherwise, calls); + } + } + Expr::For(_, iterable, body) => { + collect_calls_from_expr(iterable, calls); + collect_calls_from_expr(body, calls); + } + Expr::Pipe(lhs, rhs) => { + collect_calls_from_expr(lhs, calls); + collect_calls_from_expr(rhs, calls); + } + Expr::Lambda(_, body) => collect_calls_from_expr(body, calls), + Expr::List(items) => { + for item in items { + collect_calls_from_expr(item, calls); + } + } + Expr::Map(entries) => { + for (key, value) in entries { + collect_calls_from_expr(key, calls); + collect_calls_from_expr(value, calls); + } + } + Expr::Guarded(inner, guard) => { + collect_calls_from_expr(inner, calls); + collect_calls_from_expr(guard, calls); + } + Expr::After(inner, _) => collect_calls_from_expr(inner, calls), + Expr::Return(fields) => { + for (_, value) in fields { + collect_calls_from_expr(value, calls); + } + } + Expr::Literal(_) | Expr::Ident(_) => {} + } +} + +fn collect_service_calls_from_stmts(stmts: &[Stmt], calls: &mut Vec) { + for stmt in stmts { + match stmt { + Stmt::Let(_, expr) | Stmt::Assign(_, expr) | Stmt::Expr(expr) => { + collect_service_calls_from_expr(expr, calls); + } + Stmt::Return(fields) => { + for (_, expr) in fields { + collect_service_calls_from_expr(expr, calls); + } + } + } + } +} + +fn collect_service_calls_from_expr(expr: &Expr, calls: &mut Vec) { + match expr { + Expr::Call(_, args) => { + for (_, arg) in args { + collect_service_calls_from_expr(arg, calls); + } + } + Expr::ServiceCall(path, args) => { + calls.push(BodyServiceCall { + path: path.clone(), + arg_count: args.len(), + named_args: args + .iter() + .filter_map(|(name, _)| name.clone()) + .collect::>(), + }); + for (_, arg) in args { + collect_service_calls_from_expr(arg, calls); + } + } + Expr::FieldAccess(base, _) => collect_service_calls_from_expr(base, calls), + Expr::BinOp(lhs, _, rhs) => { + collect_service_calls_from_expr(lhs, calls); + collect_service_calls_from_expr(rhs, calls); + } + Expr::UnaryOp(_, inner) => collect_service_calls_from_expr(inner, calls), + Expr::StringInterp(parts) => { + for part in parts { + if let daglang_syntax::ast::StringPart::Expr(inner) = part { + collect_service_calls_from_expr(inner, calls); + } + } + } + Expr::Record(_, fields) => { + for (_, value) in fields { + collect_service_calls_from_expr(value, calls); + } + } + Expr::Match(scrutinee, arms) => { + collect_service_calls_from_expr(scrutinee, calls); + for arm in arms { + if let Some(guard) = &arm.guard { + collect_service_calls_from_expr(guard, calls); + } + collect_service_calls_from_expr(&arm.body, calls); + } + } + Expr::If(cond, then_expr, else_expr) => { + collect_service_calls_from_expr(cond, calls); + collect_service_calls_from_expr(then_expr, calls); + if let Some(otherwise) = else_expr { + collect_service_calls_from_expr(otherwise, calls); + } + } + Expr::For(_, iterable, body) => { + collect_service_calls_from_expr(iterable, calls); + collect_service_calls_from_expr(body, calls); + } + Expr::Pipe(lhs, rhs) => { + collect_service_calls_from_expr(lhs, calls); + collect_service_calls_from_expr(rhs, calls); + } + Expr::Lambda(_, body) => collect_service_calls_from_expr(body, calls), + Expr::List(items) => { + for item in items { + collect_service_calls_from_expr(item, calls); + } + } + Expr::Map(entries) => { + for (key, value) in entries { + collect_service_calls_from_expr(key, calls); + collect_service_calls_from_expr(value, calls); + } + } + Expr::Guarded(inner, guard) => { + collect_service_calls_from_expr(inner, calls); + collect_service_calls_from_expr(guard, calls); + } + Expr::After(inner, _) => collect_service_calls_from_expr(inner, calls), + Expr::Return(fields) => { + for (_, value) in fields { + collect_service_calls_from_expr(value, calls); + } + } + Expr::Literal(_) | Expr::Ident(_) => {} + } +} + +fn should_validate_call_name(name: &str) -> bool { + !matches!(name, "" | "as" | "with" | "fn") +} + +fn validate_type_expr( + ty: &TypeExpr, + known_types: &HashSet, + generic_arity_registry: &GenericArityRegistry, + context: &str, + errors: &mut Vec, +) { + match ty { + TypeExpr::Named(name) => { + if should_validate_named_type(name) && !known_types.contains(name) { + let tail = name.rsplit('.').next().unwrap_or(name); + if !known_types.contains(tail) { + errors.push(TypeError::UndefinedType(format!("{name} (in {context})"))); + } + } + } + TypeExpr::Generic(name, args) => { + if let Some(expected) = + resolve_generic_arity(name, generic_arity_registry, known_types) + { + if expected != args.len() { + errors.push(TypeError::ArityMismatch { + name: name.clone(), + expected, + got: args.len(), + }); + } + } + if should_validate_named_type(name) && !known_types.contains(name) { + let tail = name.rsplit('.').next().unwrap_or(name); + if !known_types.contains(tail) { + errors.push(TypeError::UndefinedType(format!("{name} (in {context})"))); + } + } + for arg in args { + validate_type_expr(arg, known_types, generic_arity_registry, context, errors); + } + } + TypeExpr::Optional(inner) => { + validate_type_expr(inner, known_types, generic_arity_registry, context, errors) + } + TypeExpr::Annotated(inner, annotations) => { + validate_type_expr(inner, known_types, generic_arity_registry, context, errors); + for annotation in annotations { + if annotation.name != "range" { + continue; + } + let (min, max) = extract_range_bounds(&annotation.args); + if let (Some(min), Some(max)) = (min, max) { + if min > max { + errors.push(TypeError::UnsatisfiableRefinement { + ty: type_expr_to_string(inner), + constraint: format!("range min {min} exceeds max {max}"), + }); + } + } + } + } + } +} + +fn resolve_generic_arity( + name: &str, + registry: &GenericArityRegistry, + known_types: &HashSet, +) -> Option { + if let Some(arity) = registry.full.get(name) { + return Some(*arity); + } + let short = name.rsplit('.').next().unwrap_or(name); + if let Some(entry) = registry.short.get(short) { + return *entry; + } + if known_types.contains(name) || known_types.contains(short) { + return Some(0); + } + None +} + +fn extract_range_bounds(args: &[Expr]) -> (Option, Option) { + let mut min = None; + let mut max = None; + for arg in args { + match arg { + Expr::Record(_, fields) => { + for (name, value) in fields { + match name.as_str() { + "min" => min = extract_int_literal(value), + "max" => max = extract_int_literal(value), + _ => {} + } + } + } + _ => { + if min.is_none() { + min = extract_int_literal(arg); + } else if max.is_none() { + max = extract_int_literal(arg); + } + } + } + } + (min, max) +} + +fn extract_int_literal(expr: &Expr) -> Option { + match expr { + Expr::Literal(daglang_syntax::ast::Literal::Int(value)) => Some(*value), + _ => None, + } +} + +fn should_validate_named_type(name: &str) -> bool { + name.chars() + .all(|ch| ch.is_alphanumeric() || matches!(ch, '_' | '.')) +} + +fn type_expr_to_string(expr: &TypeExpr) -> String { + match expr { + TypeExpr::Named(name) => name.clone(), + TypeExpr::Generic(name, args) => format!( + "{name}<{}>", + args.iter() + .map(type_expr_to_string) + .collect::>() + .join(", ") + ), + TypeExpr::Optional(inner) => format!("{}?", type_expr_to_string(inner)), + TypeExpr::Annotated(inner, annotations) => format!( + "{} {}", + type_expr_to_string(inner), + annotations + .iter() + .map(|annotation| format!("@{}", annotation.name)) + .collect::>() + .join(" ") + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + + fn module_graph_from_sources(sources: &[(&str, &str)]) -> ModuleGraph { + let modules = sources + .iter() + .map(|(path, source)| { + let ast = daglang_syntax::parser::parse(source).expect("source should parse"); + let module_path = ast + .module_path + .as_ref() + .map(|module| module.node.segments.clone()) + .expect("module declarations are required in tests"); + ResolvedModule { + path: PathBuf::from(path), + ast, + module_path, + dependencies: Vec::new(), + } + }) + .collect(); + ModuleGraph { modules } + } + + #[test] + fn typecheck_accepts_makegen_module() { + let file = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../dsl/tools/makegen.dag"); + let source = fs::read_to_string(file).expect("should read makegen source"); + let graph = module_graph_from_sources(&[("dsl/tools/makegen.dag", &source)]); + let typed = typecheck_module_graph(graph).expect("makegen should typecheck"); + + assert_eq!(typed.modules.len(), 1); + assert_eq!(typed.modules[0].module_path.join("."), "tools.makegen"); + assert!(typed.modules[0] + .signatures + .iter() + .any(|signature| matches!(signature, TypedItemSignature::Fn(_)))); + assert!(typed.modules[0] + .signatures + .iter() + .any(|signature| matches!(signature, TypedItemSignature::Func(_)))); + } + + #[test] + fn duplicate_param_names_are_reported() { + let graph = module_graph_from_sources(&[( + "dup_params.dag", + "module sample.dup\nfn bad(a: String, a: Int) -> String { a }", + )]); + let errors = typecheck_module_graph(graph).expect_err("duplicate params should fail"); + assert!(errors + .iter() + .any(|error| matches!(error, TypeError::DuplicateParameter { item, param } if item == "bad" && param == "a"))); + } + + #[test] + fn duplicate_output_fields_are_reported() { + let graph = module_graph_from_sources(&[( + "dup_outputs.dag", + r#"module sample.dup +func run() -> { ok: Bool, ok: Bool } { + return { ok: true } +} +"#, + )]); + let errors = + typecheck_module_graph(graph).expect_err("duplicate outputs should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateOutputField { item, field } + if item == "run" && field == "ok" + ))); + } + + #[test] + fn undefined_types_are_reported() { + let graph = module_graph_from_sources(&[( + "unknown_type.dag", + "module sample.unknown\nfn run(input: MissingType) -> String { \"ok\" }", + )]); + let errors = typecheck_module_graph(graph).expect_err("unknown type should fail"); + assert!(errors + .iter() + .any(|error| matches!(error, TypeError::UndefinedType(msg) if msg.contains("MissingType")))); + } + + #[test] + fn duplicate_definition_is_reported() { + let graph = module_graph_from_sources(&[( + "duplicate_definition.dag", + r#"module sample.dup +fn run() -> Unit {} +func run() -> { ok: Bool } { + return { ok: true } +} +"#, + )]); + let errors = typecheck_module_graph(graph).expect_err("duplicate item name should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateDefinition { module, name } + if module == "sample.dup" && name == "run" + ))); + } + + #[test] + fn duplicate_interface_definition_is_reported_and_causes_ambiguous_implements() { + let graph = module_graph_from_sources(&[( + "duplicate_interface_definition.dag", + r#"module sample.dup +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +"#, + )]); + let errors = typecheck_module_graph(graph) + .expect_err("duplicate interface definitions should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateDefinition { module, name } + if module == "sample.dup" && name == "Storage" + ))); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousInterface { implementor, interface } + if implementor == "FsStorage" && interface == "Storage" + ))); + } + + #[test] + fn strict_mode_duplicate_interface_definition_also_reports_ambiguous_implements() { + let graph = module_graph_from_sources(&[( + "duplicate_interface_definition_strict.dag", + r#"module sample.dup +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +"#, + )]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for duplicate interface definitions"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateDefinition { module, name } + if module == "sample.dup" && name == "Storage" + ))); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousInterface { implementor, interface } + if implementor == "FsStorage" && interface == "Storage" + ))); + } + + #[test] + fn strict_mode_reports_unresolved_imports() { + let graph = module_graph_from_sources(&[( + "missing_import.dag", + "module sample.main\nimport missing.dep\nfn run() -> Unit {}", + )]); + let options = TypecheckOptions { + allow_unresolved_imports: false, + }; + let errors = typecheck_module_graph_with_options(graph, options) + .expect_err("strict mode should fail on unresolved import"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::UnresolvedImport { module, target } + if module == "sample.main" && target == "missing.dep" + ))); + } + + #[test] + fn call_arity_mismatch_is_reported() { + let graph = module_graph_from_sources(&[( + "arity_mismatch.dag", + "module sample.calls\nfn fmt(value: String) -> String { value }\nfn run() -> String { fmt() }", + )]); + let errors = typecheck_module_graph(graph).expect_err("call arity mismatch should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::CallArityMismatch { + caller, + callee, + expected, + got + } if caller == "run" && callee == "fmt" && *expected == 1 && *got == 0 + ))); + } + + #[test] + fn call_with_too_many_args_is_reported() { + let graph = module_graph_from_sources(&[( + "arity_overflow.dag", + "module sample.calls\nfn fmt(value: String) -> String { value }\nfn run() -> String { fmt(\"a\", \"b\") }", + )]); + let errors = typecheck_module_graph(graph).expect_err("too many call args should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::CallArityMismatch { + caller, + callee, + expected, + got + } if caller == "run" && callee == "fmt" && *expected == 1 && *got == 2 + ))); + } + + #[test] + fn strict_mode_allows_call_omitting_defaulted_params() { + let graph = module_graph_from_sources(&[( + "defaulted_call.dag", + r#"module sample.calls +fn greet(name: String, punctuation: String = "!") -> String { + name +} +fn run() -> String { + greet(name: "hi") +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("defaulted callable params should be optional at call sites"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_allows_pattern_calls_with_extra_wiring_named_args() { + let graph = module_graph_from_sources(&[( + "pattern_wiring_call.dag", + r#"module sample.calls +pattern ensure(should_act: Bool = true) -> { acted: Bool } { + return { acted: should_act } +} +fn run() -> Bool { + let result = ensure(check: true, action: false) + result.acted +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("pattern calls should allow extra named wiring arguments"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_reports_ambiguous_call_target() { + let graph = module_graph_from_sources(&[ + ( + "sample/one.dag", + "module sample.one\nfn render(value: String) -> String { value }", + ), + ( + "sample/two.dag", + "module sample.two\nfn render(value: String) -> String { value }", + ), + ( + "sample/main.dag", + "module sample.main\nfn run() -> String { render(value: \"ok\") }", + ), + ]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for ambiguous callable target"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousCallTarget { caller, callee } + if caller == "run" && callee == "render" + ))); + } + + #[test] + fn strict_mode_duplicate_callable_definition_also_reports_ambiguous_call_target() { + let graph = module_graph_from_sources(&[( + "sample/main.dag", + r#"module sample.main +fn helper() -> String { "a" } +fn helper() -> String { "b" } +fn run() -> String { helper() }"#, + )]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for duplicate callable definition"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateDefinition { module, name } + if module == "sample.main" && name == "helper" + ))); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousCallTarget { caller, callee } + if caller == "run" && callee == "helper" + ))); + } + + #[test] + fn relaxed_mode_duplicate_callable_definition_suppresses_ambiguous_call_target() { + let graph = module_graph_from_sources(&[( + "sample/single.dag", + r#"module sample.single +fn helper() -> String { "a" } +fn helper() -> String { "b" } +fn run() -> String { helper() }"#, + )]); + let errors = typecheck_module_graph(graph) + .expect_err("relaxed mode should still fail for duplicate callable definition"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateDefinition { module, name } + if module == "sample.single" && name == "helper" + ))); + assert!(!errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousCallTarget { caller, callee } + if caller == "run" && callee == "helper" + ))); + } + + #[test] + fn strict_mode_reports_unresolved_call_target() { + let graph = module_graph_from_sources(&[( + "sample/main.dag", + "module sample.main\nfn run() -> String { missing(value: \"ok\") }", + )]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for unresolved callable target"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::UnresolvedCallTarget { caller, callee } + if caller == "run" && callee == "missing" + ))); + } + + #[test] + fn relaxed_mode_allows_unresolved_call_target() { + let graph = module_graph_from_sources(&[( + "sample/main.dag", + "module sample.main\nfn run() -> String { missing(value: \"ok\") }", + )]); + let typed = typecheck_module_graph(graph) + .expect("relaxed mode should allow unresolved callable target"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_accepts_collection_intrinsic_call_targets() { + let graph = module_graph_from_sources(&[( + "sample/intrinsics.dag", + r#"module sample.intrinsics +type Stage { + success: Bool, + skipped: Bool, + name: String +} +fn summarize(stages: List) -> Int { + let passed = stages |> filter(s => s.success) |> count() + let labels = stages |> map(s => s.name) |> join(",") + let done = labels |> ends_with("ok") + passed +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("collection intrinsics should be recognized in strict mode"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_accepts_std_helper_intrinsic_call_targets() { + let graph = module_graph_from_sources(&[( + "sample/helpers.dag", + r#"module sample.helpers +type DocgenSources {} + +fn run(sources: DocgenSources, payload: String) -> String { + let a = "template" |> replace_section("section", "value") + let b = render_test_listings(sources: sources) + let c = render_graph_structure(sources: sources) + let d = render_source_artifacts(sources: sources) + let e = compute_topology_diff(current: "{}", base: "{}") + let f = render_annotated_mermaid(diff: e, topology: "{}", title: "title") + let g = detect_runtime() + let h = generate() + let i = now() + let j = build_token( + payload: payload, + scheme: "Bearer", + header_name: "Authorization", + source_id: "source", + required_scopes: ["gist"] + ) + a +} +"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("std helper intrinsics should be recognized in strict mode"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_accepts_generic_fn_type_params() { + let graph = module_graph_from_sources(&[( + "sample/generic_fn.dag", + r#"module sample.generic +fn identity(value: T) -> T { + value +} +fn relay(value: T) -> T { + identity(value: value) +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("generic fn type parameters should be treated as known types"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_accepts_generic_pattern_type_params() { + let graph = module_graph_from_sources(&[( + "sample/generic_pattern.dag", + r#"module sample.generic +pattern passthrough(value: T) -> { value: T } { + return { value: value } +} +fn relay(value: T) -> T { + let result = passthrough(value: value) + result.value +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("generic pattern type parameters should be treated as known types"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_accepts_untyped_record_literal_for_named_return() { + let graph = module_graph_from_sources(&[( + "sample/records.dag", + r#"module sample.records +type StageResult { + success: Bool, + skipped: Bool +} +fn result() -> StageResult { + { success: true, skipped: false } +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("record literals should satisfy named-record return contracts"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_accepts_resource_config_named_type_returns() { + let graph = module_graph_from_sources(&[( + "sample/resources.dag", + r#"module sample.resources +resource GcsBucket { + config { + name: String, + project: String + } +} +fn gcp_dev_storage() -> GcsBucket.Config { + { name: "gunbc-dev-artifacts", project: "gunbai-auto" } +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("resource config named types should be recognized in strict mode"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_accepts_secret_builtin_type() { + let graph = module_graph_from_sources(&[( + "sample/secret.dag", + r#"module sample.secret +fn identity(value: Secret) -> Secret { + value +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("Secret should be recognized as builtin type"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_accepts_function_typed_parameter_call_targets() { + let graph = module_graph_from_sources(&[( + "sample/callback.dag", + r#"module sample.callback +fn apply(value: Int, callback: fn(Int) -> Int) -> Int { + callback(value) +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("function-typed parameters should be callable in strict mode"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn function_typed_parameter_call_reports_arity_mismatch() { + let graph = module_graph_from_sources(&[( + "sample/callback_arity.dag", + r#"module sample.callback +fn apply(callback: fn(Int) -> Int) -> Int { + callback() +}"#, + )]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("callback calls should enforce function-type arity"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::CallArityMismatch { + caller, + callee, + expected, + got + } if caller == "apply" && callee == "callback" && *expected == 1 && *got == 0 + ))); + } + + #[test] + fn strict_mode_accepts_sum_variant_constructor_call_targets() { + let graph = module_graph_from_sources(&[( + "sample/constructors.dag", + r#"module sample.constructors +type CloudConfig + = GcpConfig { project: String, region: String } + | AwsConfig { region: String } + +fn make_gcp() -> CloudConfig { + GcpConfig(project: "gunbc", region: "us-central1") +} +"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("sum variant constructors should resolve as callable targets"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn sum_variant_constructor_call_reports_arity_mismatch() { + let graph = module_graph_from_sources(&[( + "sample/constructor_arity.dag", + r#"module sample.constructors +type CloudConfig + = GcpConfig { project: String, region: String } + +fn make_gcp() -> CloudConfig { + GcpConfig(project: "gunbc") +} +"#, + )]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("variant constructor calls should enforce field arity"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::CallArityMismatch { + caller, + callee, + expected, + got + } if caller == "make_gcp" && callee == "GcpConfig" && *expected == 2 && *got == 1 + ))); + } + + #[test] + fn strict_mode_accepts_zero_arity_variant_as_identifier_value() { + let graph = module_graph_from_sources(&[( + "sample/variant_ident.dag", + r#"module sample.variant +type Environment = Dev | Ci +fn env() -> Environment { + Dev +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("zero-arity variants should be inferred from identifier expressions"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_skips_missing_tail_mismatch_for_lossy_fn_body() { + let graph = module_graph_from_sources(&[( + "sample/lossy_match.dag", + r#"module sample.lossy +type CloudConfig + = GcpConfig { project: String } + | AwsConfig { account: String } +type CloudProvider = Gcp | Aws + +fn provider_of(config: CloudConfig) -> CloudProvider { + match config { + GcpConfig { ... } => Gcp + AwsConfig { ... } => Aws + } +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("lossy fn bodies should not emit missing-tail unit mismatch diagnostics"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn unknown_named_call_argument_is_reported() { + let graph = module_graph_from_sources(&[( + "unknown_arg.dag", + "module sample.calls\nfn fmt(value: String) -> String { value }\nfn run() -> String { fmt(text: \"ok\") }", + )]); + let errors = + typecheck_module_graph(graph).expect_err("unknown named argument should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::UnknownCallArgument { + caller, + callee, + argument + } if caller == "run" && callee == "fmt" && argument == "text" + ))); + } + + #[test] + fn duplicate_named_call_argument_is_reported() { + let graph = module_graph_from_sources(&[( + "duplicate_arg.dag", + "module sample.calls\nfn fmt(value: String) -> String { value }\nfn run() -> String { fmt(value: \"a\", value: \"b\") }", + )]); + let errors = + typecheck_module_graph(graph).expect_err("duplicate named argument should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateCallArgument { + caller, + callee, + argument + } if caller == "run" && callee == "fmt" && argument == "value" + ))); + } + + #[test] + fn service_call_arity_mismatch_is_reported() { + let graph = module_graph_from_sources(&[( + "service_arity_mismatch.dag", + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run() -> { ok: Bool } { + let response = FsStorage.read() + return { ok: true } +}"#, + )]); + let errors = + typecheck_module_graph(graph).expect_err("service call arity mismatch should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::ServiceCallArityMismatch { + caller, + service_call, + expected, + got + } if caller == "run" + && service_call == "FsStorage.read" + && *expected == 1 + && *got == 0 + ))); + } + + #[test] + fn service_call_with_too_many_args_is_reported() { + let graph = module_graph_from_sources(&[( + "service_arity_overflow.dag", + r#"module sample.services +service FsStorage { + operation read(path: String) -> { body: String } +} +func run() -> { ok: Bool } { + let response = FsStorage.read(path: "/tmp", extra: "value") + return { ok: true } +}"#, + )]); + let errors = typecheck_module_graph(graph) + .expect_err("too many service call arguments should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::ServiceCallArityMismatch { + caller, + service_call, + expected, + got + } if caller == "run" + && service_call == "FsStorage.read" + && *expected == 1 + && *got == 2 + ))); + } + + #[test] + fn strict_mode_allows_service_call_omitting_defaulted_inputs() { + let graph = module_graph_from_sources(&[( + "service_default_input.dag", + r#"module sample.services +service FsStorage { + operation read(path: String, recursive: Bool = false) -> { ok: Bool } +} +func run() -> { ok: Bool } { + let response = FsStorage.read(path: "/tmp") + return { ok: response.ok } +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("service inputs with defaults should be optional at call sites"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn uses_bound_resource_capability_call_typechecks_and_infers_outputs() { + let graph = module_graph_from_sources(&[( + "resource_bound_service_call.dag", + r#"module sample.resources +resource Filesystem { + capability read { + input { path: String } + output { body: String } + } +} +func run(path: String) -> { body: String } uses fs: Filesystem { + let response = fs.read(path: path) + return { body: response.body } +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("resource-bound capability calls should typecheck in strict mode"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn uses_bound_resource_capability_call_reports_arity_mismatch() { + let graph = module_graph_from_sources(&[( + "resource_bound_service_call_arity.dag", + r#"module sample.resources +resource Filesystem { + capability read { + input { path: String } + output { body: String } + } +} +func run() -> { ok: Bool } uses fs: Filesystem { + let response = fs.read() + return { ok: true } +}"#, + )]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("resource-bound capability calls should enforce arity"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::ServiceCallArityMismatch { + caller, + service_call, + expected, + got + } if caller == "run" + && service_call == "fs.read" + && *expected == 1 + && *got == 0 + ))); + } + + #[test] + fn strict_mode_reports_unresolved_service_call() { + let graph = module_graph_from_sources(&[( + "service_unresolved_call.dag", + r#"module sample.services +func run(path: String) -> { body: String } { + let response = MissingStorage.read(path: path) + return { body: response.body } +}"#, + )]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for unresolved service call"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::UnresolvedServiceCall { + caller, + service_call + } if caller == "run" && service_call == "MissingStorage.read" + ))); + } + + #[test] + fn strict_mode_reports_ambiguous_service_call() { + let graph = module_graph_from_sources(&[ + ( + "sample/first.dag", + r#"module sample.first +service SharedService { + operation read(path: String) -> { body: String } +}"#, + ), + ( + "sample/second.dag", + r#"module sample.second +service SharedService { + operation read(path: String) -> { body: String } +}"#, + ), + ( + "sample/main.dag", + r#"module sample.main +func run(path: String) -> { body: String } { + let response = SharedService.read(path: path) + return { body: response.body } +}"#, + ), + ]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for ambiguous service call"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousServiceCall { + caller, + service_call + } if caller == "run" && service_call == "SharedService.read" + ))); + } + + #[test] + fn strict_mode_duplicate_service_definition_also_reports_ambiguous_service_call() { + let graph = module_graph_from_sources(&[( + "sample/main.dag", + r#"module sample.main +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path: path) + return { body: response.body } +}"#, + )]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for duplicate service definition"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateDefinition { module, name } + if module == "sample.main" && name == "FsStorage" + ))); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousServiceCall { + caller, + service_call + } if caller == "run" && service_call == "FsStorage.read" + ))); + } + + #[test] + fn relaxed_mode_duplicate_service_definition_suppresses_ambiguous_service_call() { + let graph = module_graph_from_sources(&[( + "sample/single.dag", + r#"module sample.single +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path: path) + return { body: response.body } +}"#, + )]); + let errors = typecheck_module_graph(graph) + .expect_err("relaxed mode should still fail for duplicate service definition"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateDefinition { module, name } + if module == "sample.single" && name == "FsStorage" + ))); + assert!(!errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousServiceCall { + caller, + service_call + } if caller == "run" && service_call == "FsStorage.read" + ))); + } + + #[test] + fn relaxed_mode_allows_unresolved_service_call() { + let graph = module_graph_from_sources(&[( + "service_unresolved_call_relaxed.dag", + r#"module sample.services +func run(path: String) -> { body: String } { + let response = MissingStorage.read(path: path) + return { body: response.body } +}"#, + )]); + let typed = typecheck_module_graph(graph) + .expect("relaxed mode should allow unresolved service call for lower-stage validation"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn unknown_named_service_call_argument_is_reported() { + let graph = module_graph_from_sources(&[( + "service_unknown_arg.dag", + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(file: path) + return { body: response.body } +}"#, + )]); + let errors = typecheck_module_graph(graph) + .expect_err("unknown named service call argument should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::UnknownServiceCallArgument { + caller, + service_call, + argument + } if caller == "run" && service_call == "FsStorage.read" && argument == "file" + ))); + } + + #[test] + fn duplicate_named_service_call_argument_is_reported() { + let graph = module_graph_from_sources(&[( + "service_duplicate_arg.dag", + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +} +func run(path: String) -> { body: String } { + let response = FsStorage.read(path: path, path: path) + return { body: response.body } +}"#, + )]); + let errors = typecheck_module_graph(graph) + .expect_err("duplicate named service call argument should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateServiceCallArgument { + caller, + service_call, + argument + } if caller == "run" && service_call == "FsStorage.read" && argument == "path" + ))); + } + + #[test] + fn resource_missing_interface_capability_is_reported() { + let graph = module_graph_from_sources(&[( + "missing_capability.dag", + r#"module sample.resources +interface ObjectStorage { + capability read { + input { path: String } + output { body: String } + } + capability write { + input { path: String, body: String } + output { ok: Bool } + } +} +resource Disk implements ObjectStorage { + capability read { + input { path: String } + output { body: String } + } +}"#, + )]); + let errors = + typecheck_module_graph(graph).expect_err("missing interface capability should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::MissingCapability { + resource, + interface, + capability + } if resource == "Disk" && interface == "ObjectStorage" && capability == "write" + ))); + } + + #[test] + fn unresolved_interface_on_resource_is_reported() { + let graph = module_graph_from_sources(&[( + "missing_interface.dag", + "module sample.resources\nresource Disk implements MissingStorage {}", + )]); + let errors = typecheck_module_graph(graph).expect_err("unknown interface should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::UnresolvedInterface { implementor, interface } + if implementor == "Disk" && interface == "MissingStorage" + ))); + } + + #[test] + fn ambiguous_interface_on_resource_is_reported() { + let graph = module_graph_from_sources(&[ + ( + "sample/first.dag", + "module sample.first\ninterface Storage { capability read { input { path: String } output { body: String } } }", + ), + ( + "sample/second.dag", + "module sample.second\ninterface Storage { capability read { input { path: String } output { body: String } } }", + ), + ( + "sample/main.dag", + "module sample.main\nresource Disk implements Storage { capability read { input { path: String } output { body: String } } }", + ), + ]); + let errors = + typecheck_module_graph(graph).expect_err("ambiguous interface should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousInterface { + implementor, + interface + } if implementor == "Disk" && interface == "Storage" + ))); + } + + #[test] + fn service_missing_interface_operation_is_reported() { + let graph = module_graph_from_sources(&[( + "missing_operation.dag", + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } + capability write { + input { path: String, body: String } + output { ok: Bool } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: String } +}"#, + )]); + let errors = typecheck_module_graph(graph).expect_err("missing operation should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::MissingOperation { + service, + interface, + operation + } if service == "FsStorage" && interface == "Storage" && operation == "write" + ))); + } + + #[test] + fn unresolved_interface_on_service_is_reported() { + let graph = module_graph_from_sources(&[( + "missing_service_interface.dag", + "module sample.services\nservice FsStorage implements MissingStorage { operation read(path: String) -> { body: String } }", + )]); + let errors = typecheck_module_graph(graph).expect_err("unknown interface should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::UnresolvedInterface { implementor, interface } + if implementor == "FsStorage" && interface == "MissingStorage" + ))); + } + + #[test] + fn ambiguous_interface_on_service_is_reported() { + let graph = module_graph_from_sources(&[ + ( + "sample/first.dag", + "module sample.first\ninterface Storage { capability read { input { path: String } output { body: String } } }", + ), + ( + "sample/second.dag", + "module sample.second\ninterface Storage { capability read { input { path: String } output { body: String } } }", + ), + ( + "sample/main.dag", + "module sample.main\nservice FsStorage implements Storage { operation read(path: String) -> { body: String } }", + ), + ]); + let errors = + typecheck_module_graph(graph).expect_err("ambiguous interface should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousInterface { + implementor, + interface + } if implementor == "FsStorage" && interface == "Storage" + ))); + } + + #[test] + fn resource_capability_signature_mismatch_is_reported() { + let graph = module_graph_from_sources(&[( + "resource_sig_mismatch.dag", + r#"module sample.resources +interface ObjectStorage { + capability read { + input { path: String } + output { body: String } + } +} +resource Disk implements ObjectStorage { + capability read { + input { path: Int } + output { body: String } + } +}"#, + )]); + let errors = typecheck_module_graph(graph).expect_err("signature mismatch should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::InterfaceSignatureMismatch { + implementor, + interface, + capability, + detail, + } if implementor == "Disk" + && interface == "ObjectStorage" + && capability == "read" + && detail.contains("input field `path` expected `String` but found `Int`") + ))); + } + + #[test] + fn service_operation_signature_mismatch_is_reported() { + let graph = module_graph_from_sources(&[( + "service_sig_mismatch.dag", + r#"module sample.services +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +service FsStorage implements Storage { + operation read(path: String) -> { body: Int } +}"#, + )]); + let errors = typecheck_module_graph(graph).expect_err("signature mismatch should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::InterfaceSignatureMismatch { + implementor, + interface, + capability, + detail, + } if implementor == "FsStorage" + && interface == "Storage" + && capability == "read" + && detail.contains("output field `body` expected `String` but found `Int`") + ))); + } + + #[test] + fn strict_mode_reports_unknown_used_resource_type() { + let graph = module_graph_from_sources(&[( + "unknown_uses.dag", + "module sample.uses\nfunc run() -> { ok: Bool } uses fs: MissingResource { return { ok: true } }", + )]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for unknown used resource type"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::UnknownUsedResourceType { + item, + binding, + resource_type, + } if item == "run" && binding == "fs" && resource_type == "MissingResource" + ))); + } + + #[test] + fn strict_mode_accepts_uses_resource_type_with_runtime_config_suffix() { + let graph = module_graph_from_sources(&[( + "sample/main.dag", + r#"module sample.main +resource Filesystem {} +func run() -> { ok: Bool } uses fs: Filesystem(mode: ReadWrite) { + return { ok: true } +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("configured resource type should resolve in strict mode"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_reports_ambiguous_used_resource_type() { + let graph = module_graph_from_sources(&[ + ( + "sample/one.dag", + "module sample.one\nresource SharedResource {}", + ), + ( + "sample/two.dag", + "module sample.two\nresource SharedResource {}", + ), + ( + "sample/main.dag", + r#"module sample.main +func run() -> { ok: Bool } uses fs: SharedResource { + return { ok: true } +}"#, + ), + ]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for ambiguous used resource type"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousUsedResourceType { + item, + binding, + resource_type, + } if item == "run" && binding == "fs" && resource_type == "SharedResource" + ))); + } + + #[test] + fn strict_mode_duplicate_resource_definition_also_reports_ambiguous_used_resource_type() { + let graph = module_graph_from_sources(&[( + "sample/main.dag", + r#"module sample.main +resource SharedResource {} +resource SharedResource {} +func run() -> { ok: Bool } uses fs: SharedResource { + return { ok: true } +}"#, + )]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for duplicate resource definitions"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateDefinition { module, name } + if module == "sample.main" && name == "SharedResource" + ))); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousUsedResourceType { + item, + binding, + resource_type, + } if item == "run" && binding == "fs" && resource_type == "SharedResource" + ))); + } + + #[test] + fn relaxed_mode_allows_unknown_used_resource_type() { + let graph = module_graph_from_sources(&[( + "unknown_uses_relaxed.dag", + "module sample.uses\nfunc run() -> { ok: Bool } uses fs: MissingResource { return { ok: true } }", + )]); + let typed = typecheck_module_graph(graph).expect("relaxed mode should allow unknown uses"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn relaxed_mode_duplicate_resource_definition_suppresses_ambiguous_used_resource_type() { + let graph = module_graph_from_sources(&[( + "sample/single.dag", + r#"module sample.single +resource SharedResource {} +resource SharedResource {} +func run() -> { ok: Bool } uses fs: SharedResource { + return { ok: true } +}"#, + )]); + let errors = typecheck_module_graph(graph) + .expect_err("relaxed mode should still fail for duplicate resource definition"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateDefinition { module, name } + if module == "sample.single" && name == "SharedResource" + ))); + assert!(!errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousUsedResourceType { + item, + binding, + resource_type, + } if item == "run" && binding == "fs" && resource_type == "SharedResource" + ))); + } + + #[test] + fn strict_mode_reports_unknown_provided_resource_type() { + let graph = module_graph_from_sources(&[( + "unknown_provides.dag", + "module sample.provides\nfunc run() -> { ok: Bool } provides out: MissingResource { return { ok: true } }", + )]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for unknown provided resource type"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::UnknownProvidedResourceType { + item, + binding, + resource_type, + } if item == "run" && binding == "out" && resource_type == "MissingResource" + ))); + } + + #[test] + fn strict_mode_accepts_provides_resource_type_with_runtime_config_suffix() { + let graph = module_graph_from_sources(&[( + "sample/main.dag", + r#"module sample.main +resource ArtifactStore {} +func run() -> { ok: Bool } provides out: ArtifactStore(kind: temporary) { + return { ok: true } +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("configured provided resource type should resolve in strict mode"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_accepts_annotated_provides_resource_type_reference() { + let graph = module_graph_from_sources(&[( + "sample/main.dag", + r#"module sample.main +resource ArtifactStore {} +func run() -> { ok: Bool } provides out: ArtifactStore @test_integration { + return { ok: true } +}"#, + )]); + let typed = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect("annotated provided resource type should resolve in strict mode"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn strict_mode_reports_ambiguous_provided_resource_type() { + let graph = module_graph_from_sources(&[ + ( + "sample/one.dag", + "module sample.one\nresource SharedResource {}", + ), + ( + "sample/two.dag", + "module sample.two\nresource SharedResource {}", + ), + ( + "sample/main.dag", + r#"module sample.main +func run() -> { ok: Bool } provides out: SharedResource { + return { ok: true } +}"#, + ), + ]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for ambiguous provided resource type"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousProvidedResourceType { + item, + binding, + resource_type, + } if item == "run" && binding == "out" && resource_type == "SharedResource" + ))); + } + + #[test] + fn relaxed_mode_duplicate_resource_definition_suppresses_ambiguous_provided_resource_type() { + let graph = module_graph_from_sources(&[( + "sample/single.dag", + r#"module sample.single +resource SharedResource {} +resource SharedResource {} +func run() -> { ok: Bool } provides out: SharedResource { + return { ok: true } +}"#, + )]); + let errors = typecheck_module_graph(graph) + .expect_err("relaxed mode should still fail for duplicate resource definition"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateDefinition { module, name } + if module == "sample.single" && name == "SharedResource" + ))); + assert!(!errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousProvidedResourceType { + item, + binding, + resource_type, + } if item == "run" && binding == "out" && resource_type == "SharedResource" + ))); + } + + #[test] + fn strict_mode_duplicate_resource_definition_also_reports_ambiguous_provided_resource_type() { + let graph = module_graph_from_sources(&[( + "sample/main.dag", + r#"module sample.main +resource SharedResource {} +resource SharedResource {} +func run() -> { ok: Bool } provides out: SharedResource { + return { ok: true } +}"#, + )]); + let errors = typecheck_module_graph_with_options( + graph, + TypecheckOptions { + allow_unresolved_imports: false, + }, + ) + .expect_err("strict mode should fail for duplicate resource definitions"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateDefinition { module, name } + if module == "sample.main" && name == "SharedResource" + ))); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::AmbiguousProvidedResourceType { + item, + binding, + resource_type, + } if item == "run" && binding == "out" && resource_type == "SharedResource" + ))); + } + + #[test] + fn duplicate_uses_binding_is_reported() { + let graph = module_graph_from_sources(&[( + "duplicate_uses.dag", + r#"module sample.uses +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +func run() -> { ok: Bool } uses fs: Storage uses fs: Storage { + return { ok: true } +}"#, + )]); + let errors = typecheck_module_graph(graph).expect_err("duplicate uses should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateUsesBinding { item, binding } + if item == "run" && binding == "fs" + ))); + } + + #[test] + fn duplicate_provides_binding_is_reported() { + let graph = module_graph_from_sources(&[( + "duplicate_provides.dag", + r#"module sample.provides +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +func run() -> { ok: Bool } provides out: Storage provides out: Storage { + return { ok: true } +}"#, + )]); + let errors = typecheck_module_graph(graph).expect_err("duplicate provides should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::DuplicateProvidesBinding { item, binding } + if item == "run" && binding == "out" + ))); + } + + #[test] + fn use_provide_binding_conflict_is_reported() { + let graph = module_graph_from_sources(&[( + "use_provide_conflict.dag", + r#"module sample.conflict +interface Storage { + capability read { + input { path: String } + output { body: String } + } +} +func run() -> { ok: Bool } uses io: Storage provides io: Storage { + return { ok: true } +}"#, + )]); + let errors = typecheck_module_graph(graph).expect_err("binding conflict should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::UseProvideBindingConflict { item, binding } + if item == "run" && binding == "io" + ))); + } + + #[test] + fn type_mismatch_in_fn_return_is_reported() { + let graph = module_graph_from_sources(&[( + "type_mismatch_fn_return.dag", + r#"module sample.types +fn run() -> String { return 42 }"#, + )]); + let errors = typecheck_module_graph(graph).expect_err("type mismatch should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::TypeMismatch { expected, got } + if expected == "String" && got == "Int" + ))); + } + + #[test] + fn implicit_type_mismatch_in_fn_return_is_reported() { + let graph = module_graph_from_sources(&[( + "implicit_type_mismatch_fn_return.dag", + r#"module sample.types +fn run() -> String { 42 }"#, + )]); + let errors = typecheck_module_graph(graph) + .expect_err("implicit return type mismatch should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::TypeMismatch { expected, got } + if expected == "String" && got == "Int" + ))); + } + + #[test] + fn missing_tail_expression_in_fn_return_is_reported_as_unit_mismatch() { + let graph = module_graph_from_sources(&[( + "missing_tail_expression_fn_return.dag", + r#"module sample.types +fn run() -> String { let x = 42 }"#, + )]); + let errors = typecheck_module_graph(graph) + .expect_err("missing tail expression should fail for non-unit return type"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::TypeMismatch { expected, got } + if expected == "String" && got == "Unit" + ))); + } + + #[test] + fn missing_tail_expression_is_allowed_for_unit_return_type() { + let graph = module_graph_from_sources(&[( + "missing_tail_expression_unit_return.dag", + r#"module sample.types +fn run() -> Unit { let x = 42 }"#, + )]); + let typed = typecheck_module_graph(graph).expect("unit return type should allow no tail expression"); + assert_eq!(typed.modules.len(), 1); + } + + #[test] + fn no_such_field_on_record_literal_is_reported() { + let graph = module_graph_from_sources(&[( + "no_such_field.dag", + r#"module sample.fields +func run() -> { body: String } { + let payload = { body: "ok" } + return { body: payload.missing } +}"#, + )]); + let errors = typecheck_module_graph(graph).expect_err("no such field should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::NoSuchField { ty, field } if ty == "Record" && field == "missing" + ))); + } + + #[test] + fn no_such_field_on_named_record_type_is_reported() { + let graph = module_graph_from_sources(&[( + "no_such_field_named_record.dag", + r#"module sample.fields +type Payload { body: String } +fn run(input: Payload) -> String { input.missing }"#, + )]); + let errors = + typecheck_module_graph(graph).expect_err("no such field on named record should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::NoSuchField { ty, field } if ty == "Payload" && field == "missing" + ))); + } + + #[test] + fn unsatisfiable_refinement_is_reported() { + let graph = module_graph_from_sources(&[( + "unsat_refinement.dag", + r#"module sample.refinement +fn run(value: Int @range(min: 5, max: 1)) -> Int { value }"#, + )]); + let errors = typecheck_module_graph(graph).expect_err("unsatisfiable range should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::UnsatisfiableRefinement { ty, constraint } + if ty == "Int" && constraint.contains("min 5 exceeds max 1") + ))); + } + + #[test] + fn generic_arity_mismatch_is_reported() { + let graph = module_graph_from_sources(&[( + "generic_arity_mismatch.dag", + r#"module sample.generics +fn run(items: Map) -> Int { 1 }"#, + )]); + let errors = typecheck_module_graph(graph).expect_err("generic arity mismatch should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::ArityMismatch { + name, + expected, + got, + } if name == "Map" && *expected == 2 && *got == 1 + ))); + } + + #[test] + fn user_defined_generic_arity_mismatch_is_reported() { + let graph = module_graph_from_sources(&[( + "user_generic_arity_mismatch.dag", + r#"module sample.generics +type Box = T +fn run(value: Box) -> String { value }"#, + )]); + let errors = typecheck_module_graph(graph) + .expect_err("user-defined generic arity mismatch should fail"); + assert!(errors.iter().any(|error| matches!( + error, + TypeError::ArityMismatch { + name, + expected, + got, + } if name == "Box" && *expected == 1 && *got == 2 + ))); + } }