diff --git a/core/daglang/daglang-cli/src/compile.rs b/core/daglang/daglang-cli/src/compile.rs index ec08ffa1b23..76dad7879b7 100644 --- a/core/daglang/daglang-cli/src/compile.rs +++ b/core/daglang/daglang-cli/src/compile.rs @@ -1,24 +1,18 @@ use std::fmt::Write; use std::path::PathBuf; -use std::{collections::{BTreeMap, BTreeSet, HashMap, VecDeque}, fmt}; +use std::collections::{BTreeSet, HashMap}; use crate::path_utils; use crate::pipeline::PipelineContext; -use daglang_derive::{derive_artifacts, DerivedArtifacts}; +use daglang_derive::{derive_artifacts, DerivedArtifacts, TestObligations}; use daglang_emit::{emit_rust_bundle, EmissionBundle}; -use daglang_lower::{compare_makegen_topology, lower_typed_project, LoweredOp, ParityReport}; +use daglang_lower::{lower_typed_project, LoweredOp}; use daglang_resolve::{ModuleGraph, ResolveError, ResolvedModule}; use daglang_syntax::diagnostic; use daglang_syntax::parser; use daglang_typecheck::{typecheck_module_graph_with_options, TypecheckOptions}; -use gunbc_exec::{ - execute_with_mode_and_inputs, BoundaryMocks, ExecError, Executable, ExecutionLog, - ExecutionMode, OutputMap, -}; -use gunbc_ir::{Dag, Edge, Node, Port}; -use gunbc_ir::Value; -use gunbc_ir::transport::{FileOp, FileRequest, FileResponse, TransportRequest, TransportResponse}; -use serde::Serialize; +use gunbc_ir::{Dag, Node}; +use serde_json::json; #[derive(Debug)] pub struct CompileOutput { @@ -28,555 +22,29 @@ pub struct CompileOutput { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ManifestFormat { +pub enum OutputFormat { Text, Json, } -impl ManifestFormat { - pub fn parse(value: &str) -> Option { - match value { - "text" => Some(Self::Text), - "json" => Some(Self::Json), - _ => None, - } - } -} - -#[derive(Debug, Clone)] -pub enum ResolvedOp { - LoadRegistry, - RenderMakefile, - FsEnv, - PrepareFileRead, - PrepareFileWrite, - CompareContent, - TransportExecute, - FinalizeMakegen, -} - -impl Executable for ResolvedOp { - fn execute(&self, inputs: HashMap) -> Result, ExecError> { - match self { - Self::LoadRegistry => execute_load_registry(inputs), - Self::RenderMakefile => execute_render_makefile(inputs), - Self::FsEnv => execute_fs_env(inputs), - Self::PrepareFileRead => execute_prepare_file_read(inputs), - Self::PrepareFileWrite => execute_prepare_file_write(inputs), - Self::CompareContent => execute_compare_content(inputs), - Self::TransportExecute => execute_transport(inputs), - Self::FinalizeMakegen => execute_finalize_makegen(inputs), - } - } -} - -fn execute_load_registry(_inputs: HashMap) -> Result, ExecError> { - OutputMap::new() - .json( - "registry", - serde_json::json!({ - "tools": [ - { - "name": "makegen", - "command": "cargo run -p gunbc-dag --bin gunbc-makegen", - } - ] - }), - ) - .ok() -} - -fn execute_render_makefile( - _inputs: HashMap, -) -> Result, ExecError> { - let content = "# Generated by daglang\n.PHONY: makegen\n\nmakegen:\n\tcargo run -p gunbc-dag --bin gunbc-makegen\n"; - OutputMap::new().str("return", content).ok() -} - -fn execute_fs_env(_inputs: HashMap) -> Result, ExecError> { - OutputMap::new() - .str("FilesystemHandle", "filesystem://workspace") - .ok() -} - -fn execute_prepare_file_read( - inputs: HashMap, -) -> Result, ExecError> { - let path = inputs - .get("path") - .and_then(Value::as_str) - .ok_or_else(|| ExecError::new("missing required input `path`".to_string()))?; - OutputMap::new() - .request("request", TransportRequest::File(FileRequest::read(path))) - .bool("skip", false) - .ok() -} - -fn execute_prepare_file_write( - inputs: HashMap, -) -> Result, ExecError> { - let path = inputs - .get("path") - .and_then(Value::as_str) - .ok_or_else(|| ExecError::new("missing required input `path`".to_string()))?; - let content = inputs - .get("content") - .and_then(Value::as_str) - .ok_or_else(|| ExecError::new("missing required input `content`".to_string()))?; - OutputMap::new() - .request("request", TransportRequest::File(FileRequest::write(path, content))) - .bool("skip", false) - .ok() -} - -fn execute_compare_content( - inputs: HashMap, -) -> Result, ExecError> { - let expected = inputs - .get("expected_content") - .and_then(Value::as_str) - .ok_or_else(|| ExecError::new("missing required input `expected_content`".to_string()))?; - let check_mode = inputs - .get("check_mode") - .and_then(Value::as_bool) - .unwrap_or(false); - let actual = match inputs.get("response") { - Some(Value::Response(TransportResponse::File(file_response))) if file_response.success => { - file_response.content.clone().unwrap_or_default() - } - _ => String::new(), - }; - let fresh = actual == expected; - let skip = fresh || check_mode; - let skip_reason = if fresh { - "content is fresh — write skipped".to_string() - } else if check_mode { - "check mode — write skipped".to_string() - } else { - String::new() - }; - OutputMap::new() - .bool("fresh", fresh) - .bool("skip", skip) - .str("skip_reason", skip_reason) - .ok() -} - -fn execute_transport(inputs: HashMap) -> Result, ExecError> { - let skip = inputs.get("skip").and_then(Value::as_bool).unwrap_or(false); - if skip { - return OutputMap::new() - .value("response", Value::Skipped) - .value("makegen_response", Value::Skipped) - .bool("skip", true) - .str("skip_reason", "transport skipped") - .ok(); - } - let request = inputs - .get("request") - .and_then(Value::as_request) - .ok_or_else(|| ExecError::new("missing required input `request`".to_string()))?; - match request { - TransportRequest::File(file_request) => { - let response = execute_file_request(&file_request); - if file_request.operation == FileOp::Write && !response.success { - let error = response - .error - .clone() - .unwrap_or_else(|| "unknown write failure".to_string()); - return Err(ExecError::new(format!( - "failed to write `{}`: {error}", - file_request.path - ))); - } - let mut output = OutputMap::new() - .response("response", TransportResponse::File(response.clone())) - .bool("skip", false); - if file_request.operation == FileOp::Write { - output = output - .response("makegen_response", TransportResponse::File(response.clone())) - .str("makegen_written_path", file_request.path.clone()) - .str( - "makegen_content", - file_request.content.clone().unwrap_or_default(), - ); - } - output.ok() - } - _ => Err(ExecError::new( - "only file transport requests are supported in daglang-cli runtime".to_string(), - )), - } -} - -fn execute_file_request(request: &FileRequest) -> FileResponse { - match request.operation { - FileOp::Read => match std::fs::read_to_string(&request.path) { - Ok(content) => FileResponse::read_ok(request.path.clone(), content), - Err(error) => FileResponse::error(request.path.clone(), FileOp::Read, error.to_string()), - }, - FileOp::Write => { - let write_result = (|| -> Result<(), std::io::Error> { - if request.create_parents { - if let Some(parent) = std::path::Path::new(&request.path).parent() { - std::fs::create_dir_all(parent)?; - } - } - std::fs::write(&request.path, request.content.clone().unwrap_or_default())?; - Ok(()) - })(); - match write_result { - Ok(()) => FileResponse::written(request.path.clone()), - Err(error) => FileResponse::error(request.path.clone(), FileOp::Write, error.to_string()), - } - } - _ => FileResponse::error( - request.path.clone(), - request.operation, - "unsupported file operation", - ), - } -} - -fn execute_finalize_makegen(inputs: HashMap) -> Result, ExecError> { - let written = !inputs.values().any(value_indicates_skip); - OutputMap::new().bool("written", written).ok() -} - -fn value_indicates_skip(value: &Value) -> bool { - match value { - Value::Skipped => true, - Value::List(values) => values.iter().any(value_indicates_skip), - Value::Map(values) => values.values().any(value_indicates_skip), - _ => false, - } -} - -#[derive(Debug, Clone, Copy, Default)] -pub struct OpRegistry; - -#[derive(Debug)] -pub enum ResolveDagError { - UnsupportedNode { - node_id: String, - reason: String, - }, -} - -impl fmt::Display for ResolveDagError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::UnsupportedNode { node_id, reason } => { - write!(f, "unsupported node `{node_id}`: {reason}") - } - } - } -} - -impl OpRegistry { - pub fn makegen() -> Self { - Self - } - - fn resolve_node(&self, node: &Node) -> Result { - let op = match &node.body { - gunbc_ir::node::NodeBody::Opaque(op) => op, - gunbc_ir::node::NodeBody::SubDag(_) => { - return Err(ResolveDagError::UnsupportedNode { - node_id: node.id.0.clone(), - reason: "subdag resolution is not yet supported".to_string(), - }); - } - }; - resolve_makegen_op(node, op) - } -} - -pub fn resolve_dag( - dag: Dag, - registry: &OpRegistry, -) -> Result, ResolveDagError> { - let Dag { nodes, edges } = dag; - let mut resolved = Dag::new(); - for node in nodes { - let resolved_op = registry.resolve_node(&node)?; - resolved.add_node(Node::opaque(node.id, node.inputs, node.outputs, resolved_op)); - } - resolved.edges = edges; - Ok(resolved) -} - -fn resolve_makegen_op( - node: &Node, - op: &LoweredOp, -) -> Result { - let LoweredOp::Callable { module, name, .. } = op else { - return Err(ResolveDagError::UnsupportedNode { - node_id: node.id.0.clone(), - reason: "pipeline lowering is not executable yet".to_string(), - }); - }; - - if node.id.0 == "tools.makegen::render_makefile" - || (module == "tools.makegen" && name == "render_makefile") - { - return Ok(ResolvedOp::RenderMakefile); - } - if node.id.0 == "load_registry" || name == "load_registry" { - return Ok(ResolvedOp::LoadRegistry); - } - if node.id.0 == "fs_env" || name == "fs_env" { - return Ok(ResolvedOp::FsEnv); - } - if node.id.0 == "tools.makegen::makegen" { - return Ok(ResolvedOp::FinalizeMakegen); - } - if name.starts_with("content_upsert::prepare_read_") { - return Ok(ResolvedOp::PrepareFileRead); - } - if name.starts_with("content_upsert::prepare_write_") { - return Ok(ResolvedOp::PrepareFileWrite); - } - if name.starts_with("content_upsert::compare_") { - return Ok(ResolvedOp::CompareContent); - } - if name.starts_with("content_upsert::execute_") { - return Ok(ResolvedOp::TransportExecute); - } - - Err(ResolveDagError::UnsupportedNode { - node_id: node.id.0.clone(), - reason: format!("no runtime mapping for `{module}.{name}`"), - }) -} - -pub fn execute_compiled_dag( - dag: &Dag, - mode: ExecutionMode, - input_mocks: Option<&BoundaryMocks>, -) -> Result { - execute_with_mode_and_inputs(dag, mode, input_mocks) - .map_err(|error| format!("execution error: {error}")) -} - -pub fn compile_resolve_execute_from_context( - context: &PipelineContext, - mode: ExecutionMode, - input_mocks: Option<&BoundaryMocks>, -) -> Result { - let output = compile_from_context(context)?; - let resolved = resolve_dag(output.lowered_dag, &OpRegistry::makegen()) - .map_err(|error| format!("resolve error: {error}"))?; - execute_compiled_dag(&resolved, mode, input_mocks) -} - -pub fn makegen_entrypoint_mocks(output_path: &str, check_mode: bool) -> BoundaryMocks { - let mut input_mocks = BoundaryMocks::new(); - input_mocks.set_input( - "prepare_read_makegen", - "path", - Value::Str(output_path.to_string()), - ); - input_mocks.set_input( - "prepare_write_makegen", - "path", - Value::Str(output_path.to_string()), - ); - input_mocks.set_input( - "compare_makegen_content", - "check_mode", - Value::Bool(check_mode), - ); - input_mocks.set_input( - "tools.makegen::render_makefile", - "__deps", - Value::List(Vec::new()), - ); - input_mocks -} - -pub fn makegen_dry_run_transport_mocks(output_path: &str) -> BoundaryMocks { - let mut dry_run_mocks = BoundaryMocks::new(); - dry_run_mocks.set_value( - "fs_env", - "FilesystemHandle", - Value::Str("filesystem://dry-run".to_string()), - ); - dry_run_mocks.set_value( - "execute_read_makegen", - "response", - Value::Response(TransportResponse::File(FileResponse::read_ok( - output_path.to_string(), - "", - ))), - ); - dry_run_mocks.set_value( - "execute_makegen_transport", - "makegen_response", - Value::Response(TransportResponse::File(FileResponse::written( - output_path.to_string(), - ))), - ); - dry_run_mocks -} - -pub fn makegen_check_mode_transport_mocks(output_path: &str) -> BoundaryMocks { - let mut check_mode_mocks = BoundaryMocks::new(); - check_mode_mocks.set_value( - "fs_env", - "FilesystemHandle", - Value::Str("filesystem://check-mode".to_string()), - ); - let read_response = match std::fs::read(output_path) { - Ok(content) => FileResponse::read_ok( - output_path.to_string(), - String::from_utf8_lossy(&content).to_string(), - ), - Err(error) => FileResponse::error(output_path.to_string(), FileOp::Read, error.to_string()), - }; - check_mode_mocks.set_value( - "execute_read_makegen", - "response", - Value::Response(TransportResponse::File(read_response)), - ); - check_mode_mocks.set_value("execute_makegen_transport", "response", Value::Skipped); - check_mode_mocks.set_value( - "execute_makegen_transport", - "makegen_response", - Value::Skipped, - ); - check_mode_mocks.set_value("execute_makegen_transport", "skip", Value::Bool(true)); - check_mode_mocks -} - -pub fn compare_compiled_makegen_against_builder( - context: &PipelineContext, -) -> Result { - let output = compile_from_context(context)?; - let reference_topology = reference_makegen_shape(); - Ok(compare_makegen_topology( - &output.lowered_dag, - &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 -} - -pub fn build_context(input: Option<&String>) -> PipelineContext { - let parsed = input.map(|value| path_utils::normalize_cli_path(PathBuf::from(value))); +/// Builds compile pipeline context from CLI input. +/// +/// Compatibility note: paths ending in `.dag` are always treated as +/// single-file targets, even when they point to a directory. +/// Integration tests lock this behavior for lowercase/uppercase/mixed-case +/// extensions and trailing-slash variants. +pub fn build_context(cwd: &std::path::Path, input: Option<&String>) -> PipelineContext { + let parsed = input.map(|value| path_utils::normalize_cli_path(cwd, &PathBuf::from(value))); let (roots, target_file) = match parsed { Some(path) if path_utils::has_dag_extension(&path) => { let root = path .parent() .map(PathBuf::from) - .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| ".".into())); + .unwrap_or_else(|| cwd.to_path_buf()); (vec![root], Some(path)) } Some(path) => (vec![path], None), - None => (vec![path_utils::resolve_default_root()], None), + None => (vec![path_utils::resolve_default_root(cwd)], None), }; PipelineContext { roots, target_file } @@ -710,7 +178,12 @@ pub fn render_expand(dag: &Dag) -> String { 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 }) => { + gunbc_ir::node::NodeBody::Opaque(LoweredOp::Callable { + kind, + module, + name, + .. + }) => { format!("callable::{kind:?} {module}.{name}") } gunbc_ir::node::NodeBody::Opaque(LoweredOp::Pipeline { @@ -747,138 +220,8 @@ pub fn render_expand(dag: &Dag) -> String { out } -pub fn render_viz_ascii(title: &str, dag: &Dag) -> String { - let mut out = String::new(); - writeln!(out, "ASCII DAG: {title}").ok(); - - out.push_str("Nodes:\n"); - let mut nodes = dag.nodes.iter().collect::>(); - nodes.sort_by(|left, right| left.id.0.cmp(&right.id.0)); - for node in nodes { - writeln!(out, " - {}", node.id.0).ok(); - if node.inputs.is_empty() { - out.push_str(" inputs: (none)\n"); - } else { - let mut inputs = node.inputs.iter().collect::>(); - inputs.sort_by(|left, right| left.name.0.cmp(&right.name.0)); - let rendered_inputs = inputs - .into_iter() - .map(|input| format!("{}:{}({})", input.name.0, input.type_id.0, input.cardinality)) - .collect::>(); - writeln!(out, " inputs: {}", rendered_inputs.join(", ")).ok(); - } - if node.outputs.is_empty() { - out.push_str(" outputs: (none)\n"); - } else { - let mut outputs = node.outputs.iter().collect::>(); - outputs.sort_by(|left, right| left.name.0.cmp(&right.name.0)); - let rendered_outputs = outputs - .into_iter() - .map(|output| format!("{}:{}({})", output.name.0, output.type_id.0, output.cardinality)) - .collect::>(); - writeln!(out, " outputs: {}", rendered_outputs.join(", ")).ok(); - } - } - - out.push_str("Edges:\n"); - if dag.edges.is_empty() { - out.push_str(" (none)\n"); - } else { - let mut edges = dag.edges.iter().collect::>(); - edges.sort_by(|left, right| { - ( - left.from_node.0.as_str(), - left.from_port.0.as_str(), - left.to_node.0.as_str(), - left.to_port.0.as_str(), - ) - .cmp(&( - right.from_node.0.as_str(), - right.from_port.0.as_str(), - right.to_node.0.as_str(), - right.to_port.0.as_str(), - )) - }); - for edge in edges { - writeln!( - out, - " - {}.{} -> {}.{}", - edge.from_node.0, edge.from_port.0, edge.to_node.0, edge.to_port.0 - ) - .ok(); - } - } - - out.push_str("Waves:\n"); - for (index, wave) in compute_ascii_waves(dag).iter().enumerate() { - writeln!(out, " [{index}] {}", wave.join(", ")).ok(); - } - out -} - -fn compute_ascii_waves(dag: &Dag) -> Vec> { - let mut indegree = dag - .nodes - .iter() - .map(|node| (node.id.0.clone(), 0usize)) - .collect::>(); - let mut outgoing = dag - .nodes - .iter() - .map(|node| (node.id.0.clone(), Vec::::new())) - .collect::>(); - - for edge in &dag.edges { - if let Some(target_degree) = indegree.get_mut(&edge.to_node.0) { - *target_degree += 1; - } - if let Some(edges) = outgoing.get_mut(&edge.from_node.0) { - edges.push(edge.to_node.0.clone()); - } - } - - let mut current_wave = indegree - .iter() - .filter_map(|(node_id, degree)| (*degree == 0).then_some(node_id.clone())) - .collect::>(); - current_wave.sort(); - - 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_id in ¤t_wave { - if let Some(targets) = outgoing.get(node_id.as_str()) { - for target in targets { - if let Some(degree) = indegree.get_mut(target) { - *degree = degree.saturating_sub(1); - if *degree == 0 { - next_wave.push(target.clone()); - } - } - } - } - } - next_wave.sort(); - next_wave.dedup(); - current_wave = next_wave; - } - - if processed != dag.nodes.len() { - vec!["(cyclic graph)".to_string()] - .into_iter() - .map(|entry| vec![entry]) - .collect() - } else { - waves - } -} - 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"); writeln!(out, " total_nodes: {}", manifest.total_nodes).ok(); @@ -903,6 +246,32 @@ pub fn render_manifest(derived: &DerivedArtifacts) -> String { manifest.boundary_nodes.join(", ") } ).ok(); + out.push_str(&render_obligations_text(&derived.obligations)); + out +} + +pub fn render_obligations(derived: &DerivedArtifacts, format: OutputFormat) -> String { + let obligations = &derived.obligations; + match format { + OutputFormat::Text => render_obligations_text(obligations), + OutputFormat::Json => json!({ + "dry_run_completion_required": obligations.dry_run_completion_required, + "transport_execution_targets": obligations.transport_execution_targets, + "pure_node_determinism_targets": obligations.pure_node_determinism_targets, + "service_transport_prepare_targets": obligations.service_transport_prepare_targets, + "service_transport_execute_targets": obligations.service_transport_execute_targets, + "service_transport_parse_targets": obligations.service_transport_parse_targets, + "service_param_source_targets": obligations.service_param_source_targets, + "resource_provide_targets": obligations.resource_provide_targets, + "resource_acquire_targets": obligations.resource_acquire_targets, + "resource_release_targets": obligations.resource_release_targets + }) + .to_string(), + } +} + +fn render_obligations_text(obligations: &TestObligations) -> String { + let mut out = String::new(); out.push_str("TestObligations:\n"); writeln!(out, " dry_run_completion_required: {}", obligations.dry_run_completion_required).ok(); writeln!(out, " transport_execution_targets: {}", obligations.transport_execution_targets).ok(); @@ -917,491 +286,110 @@ pub fn render_manifest(derived: &DerivedArtifacts) -> String { out } -pub fn render_manifest_json(derived: &DerivedArtifacts) -> Result { - #[derive(Serialize)] - struct ManifestEnvelope<'a> { - manifest: &'a daglang_derive::ProgressManifest, - obligations: &'a daglang_derive::TestObligations, - tool_metadata: &'a daglang_derive::ToolMetadata, - } - - serde_json::to_string_pretty(&ManifestEnvelope { - manifest: &derived.manifest, - obligations: &derived.obligations, - tool_metadata: &derived.tool_metadata, - }) - .map_err(|error| format!("failed to serialize manifest json: {error}")) -} - -pub fn render_manifest_with_format( - derived: &DerivedArtifacts, - format: ManifestFormat, -) -> Result { +pub fn render_triplets(dag: &Dag, format: OutputFormat) -> String { + let triplets = collect_transport_triplets(dag); match format { - ManifestFormat::Text => Ok(render_manifest(derived)), - ManifestFormat::Json => render_manifest_json(derived), - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum TripletRole { - Prepare, - Execute, - Parse, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct TripletEdge { - from_node: String, - from_port: String, - to_node: String, - to_port: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct TripletExpansion { - name: String, - prepare_nodes: Vec, - execute_nodes: Vec, - parse_nodes: Vec, - edges: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ObligationsSummary { - pub dry_run_completion_required: bool, - pub transport: ObligationsTransportBucket, - pub pure_node_determinism: ObligationsDeterminismBucket, - pub resource_lifecycle: ObligationsResourceBucket, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ObligationsTransportBucket { - pub execution_targets: usize, - pub triplet_prepare_targets: usize, - pub triplet_execute_targets: usize, - pub triplet_parse_targets: usize, - pub triplet_total_targets: usize, - pub service_param_source_targets: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ObligationsDeterminismBucket { - pub targets: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ObligationsResourceBucket { - pub provide_targets: usize, - pub acquire_targets: usize, - pub release_targets: usize, - pub total_targets: usize, -} - -fn node_callable_name(node: &Node) -> Option<&str> { - match &node.body { - gunbc_ir::node::NodeBody::Opaque(LoweredOp::Callable { name, .. }) => Some(name.as_str()), - _ => None, - } -} - -fn is_triplet_prepare(node_id: &str, callable_name: Option<&str>) -> bool { - node_id.starts_with("prepare_") - || callable_name - .map(|name| { - name.starts_with("service_transport::prepare::") - || name.contains("::prepare_") - }) - .unwrap_or(false) -} - -fn is_triplet_execute(node_id: &str, callable_name: Option<&str>) -> bool { - node_id.starts_with("execute_") - || callable_name - .map(|name| { - name.starts_with("service_transport::execute::") - || name.contains("::execute_") - }) - .unwrap_or(false) -} - -fn is_triplet_parse(node_id: &str, callable_name: Option<&str>) -> bool { - node_id.starts_with("parse_") - || node_id.starts_with("compare_") - || callable_name - .map(|name| { - name.starts_with("service_transport::parse::") - || name.contains("::parse_") - || name.contains("::compare_") - }) - .unwrap_or(false) -} - -fn classify_triplet_role(node: &Node) -> Option { - let node_id = node.id.0.as_str(); - let callable_name = node_callable_name(node); - if is_triplet_prepare(node_id, callable_name) { - return Some(TripletRole::Prepare); - } - if is_triplet_execute(node_id, callable_name) { - return Some(TripletRole::Execute); - } - if is_triplet_parse(node_id, callable_name) { - return Some(TripletRole::Parse); - } - None -} - -fn strip_triplet_prefix(node_id: &str) -> &str { - node_id - .strip_prefix("prepare_") - .or_else(|| node_id.strip_prefix("execute_")) - .or_else(|| node_id.strip_prefix("parse_")) - .or_else(|| node_id.strip_prefix("compare_")) - .unwrap_or(node_id) -} - -fn derive_triplet_name( - prepare_nodes: &[String], - execute_nodes: &[String], - parse_nodes: &[String], -) -> String { - for group in [execute_nodes, prepare_nodes, parse_nodes] { - if let Some(node_id) = group.first() { - let stripped = strip_triplet_prefix(node_id); - if !stripped.is_empty() { - return stripped.to_string(); + OutputFormat::Text => { + let mut out = String::new(); + out.push_str("TransportTriplets:\n"); + if triplets.is_empty() { + out.push_str(" (none)\n"); + return out; } - } - } - "triplet".to_string() -} - -fn derive_triplet_expansions(dag: &Dag) -> Vec { - let role_by_node = dag - .nodes - .iter() - .filter_map(|node| classify_triplet_role(node).map(|role| (node.id.0.clone(), role))) - .collect::>(); - - if role_by_node.is_empty() { - return Vec::new(); - } - - let mut adjacency = role_by_node - .keys() - .map(|node_id| (node_id.clone(), BTreeSet::::new())) - .collect::>(); - - for edge in &dag.edges { - let from = edge.from_node.0.as_str(); - let to = edge.to_node.0.as_str(); - if !role_by_node.contains_key(from) || !role_by_node.contains_key(to) { - continue; - } - adjacency - .get_mut(from) - .expect("candidate node should exist in adjacency") - .insert(to.to_string()); - adjacency - .get_mut(to) - .expect("candidate node should exist in adjacency") - .insert(from.to_string()); - } - - let mut visited = BTreeSet::::new(); - let mut components = Vec::>::new(); - for node_id in role_by_node.keys() { - if !visited.insert(node_id.clone()) { - continue; - } - let mut queue = VecDeque::from([node_id.clone()]); - let mut component = Vec::::new(); - while let Some(current) = queue.pop_front() { - component.push(current.clone()); - for neighbor in adjacency - .get(current.as_str()) - .expect("candidate node should exist in adjacency") - { - if visited.insert(neighbor.clone()) { - queue.push_back(neighbor.clone()); + for (index, triplet) in triplets.iter().enumerate() { + writeln!(out, " [{index}]").ok(); + writeln!(out, " prepare: {}", triplet.prepare_node).ok(); + writeln!(out, " execute: {}", triplet.execute_node).ok(); + if triplet.parse_nodes.is_empty() { + out.push_str(" parse: (none)\n"); + } else { + writeln!(out, " parse: {}", triplet.parse_nodes.join(", ")).ok(); } } + out } - component.sort(); - components.push(component); - } - - let edge_set = dag - .edges - .iter() - .map(|edge| { - ( - edge.from_node.0.as_str(), - edge.from_port.0.as_str(), - edge.to_node.0.as_str(), - edge.to_port.0.as_str(), - ) - }) - .collect::>(); - - let mut expansions = components - .into_iter() - .map(|component| { - let component_set = component.iter().cloned().collect::>(); - let mut prepare_nodes = Vec::new(); - let mut execute_nodes = Vec::new(); - let mut parse_nodes = Vec::new(); - for node_id in &component { - match role_by_node.get(node_id.as_str()) { - Some(TripletRole::Prepare) => prepare_nodes.push(node_id.clone()), - Some(TripletRole::Execute) => execute_nodes.push(node_id.clone()), - Some(TripletRole::Parse) => parse_nodes.push(node_id.clone()), - None => {} - } - } - - let mut edges = edge_set + OutputFormat::Json => { + let triplets_json = triplets .iter() - .filter(|(from_node, _, to_node, _)| { - component_set.contains(*from_node) && component_set.contains(*to_node) - }) - .map(|(from_node, from_port, to_node, to_port)| TripletEdge { - from_node: (*from_node).to_string(), - from_port: (*from_port).to_string(), - to_node: (*to_node).to_string(), - to_port: (*to_port).to_string(), + .map(|triplet| { + json!({ + "prepare_node": triplet.prepare_node, + "execute_node": triplet.execute_node, + "parse_nodes": triplet.parse_nodes + }) }) .collect::>(); - edges.sort_by(|left, right| { - ( - left.from_node.as_str(), - left.from_port.as_str(), - left.to_node.as_str(), - left.to_port.as_str(), - ) - .cmp(&( - right.from_node.as_str(), - right.from_port.as_str(), - right.to_node.as_str(), - right.to_port.as_str(), - )) - }); - - TripletExpansion { - name: derive_triplet_name(&prepare_nodes, &execute_nodes, &parse_nodes), - prepare_nodes, - execute_nodes, - parse_nodes, - edges, - } - }) - .collect::>(); - expansions.sort_by(|left, right| left.name.cmp(&right.name)); - expansions -} - -fn format_list_or_none(values: &[String]) -> String { - if values.is_empty() { - "(none)".to_string() - } else { - values.join(", ") + json!({ "triplets": triplets_json }).to_string() + } } } -pub fn render_triplets(dag: &Dag) -> String { - let expansions = derive_triplet_expansions(dag); - let mut out = String::new(); - out.push_str("TransportTriplets:\n"); - if expansions.is_empty() { - out.push_str(" (none)\n"); - return out; - } - for expansion in &expansions { - writeln!(out, " - name: {}", expansion.name).ok(); - writeln!( - out, - " prepare_nodes: {}", - format_list_or_none(&expansion.prepare_nodes) - ) - .ok(); - writeln!( - out, - " execute_nodes: {}", - format_list_or_none(&expansion.execute_nodes) - ) - .ok(); - writeln!( - out, - " parse_nodes: {}", - format_list_or_none(&expansion.parse_nodes) - ) - .ok(); - out.push_str(" edges:\n"); - if expansion.edges.is_empty() { - out.push_str(" * (none)\n"); - } else { - for edge in &expansion.edges { - writeln!( - out, - " * {}.{} -> {}.{}", - edge.from_node, edge.from_port, edge.to_node, edge.to_port - ) - .ok(); - } - } - } - out +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct TransportTriplet { + prepare_node: String, + execute_node: String, + parse_nodes: Vec, } -pub fn render_triplets_json(dag: &Dag) -> Result { - #[derive(Serialize)] - struct TripletsEnvelope { - triplets: Vec, - } +fn collect_transport_triplets(dag: &Dag) -> Vec { + let node_by_id = dag + .nodes + .iter() + .map(|node| (node.id.0.as_str(), node)) + .collect::>(); + let mut unique = BTreeSet::::new(); - serde_json::to_string_pretty(&TripletsEnvelope { - triplets: derive_triplet_expansions(dag), - }) - .map_err(|error| format!("failed to serialize triplets json: {error}")) -} + for edge in &dag.edges { + let Some(prepare_node) = node_by_id.get(edge.from_node.0.as_str()).copied() else { + continue; + }; + let Some(execute_node) = node_by_id.get(edge.to_node.0.as_str()).copied() else { + continue; + }; + if node_output_port_type(prepare_node, edge.from_port.0.as_str()) != Some("TransportRequest") + || node_input_port_type(execute_node, edge.to_port.0.as_str()) != Some("TransportRequest") + { + continue; + } -pub fn render_triplets_with_format( - dag: &Dag, - format: ManifestFormat, -) -> Result { - match format { - ManifestFormat::Text => Ok(render_triplets(dag)), - ManifestFormat::Json => render_triplets_json(dag), - } -} + let mut parse_nodes = dag + .edges + .iter() + .filter(|next_edge| next_edge.from_node.0 == edge.to_node.0) + .filter_map(|next_edge| { + let parse_node = node_by_id.get(next_edge.to_node.0.as_str()).copied()?; + (node_output_port_type(execute_node, next_edge.from_port.0.as_str()) + == Some("TransportResponse") + && node_input_port_type(parse_node, next_edge.to_port.0.as_str()) + == Some("TransportResponse")) + .then_some(next_edge.to_node.0.clone()) + }) + .collect::>(); + parse_nodes.sort(); + parse_nodes.dedup(); -pub fn summarize_obligations(derived: &DerivedArtifacts) -> ObligationsSummary { - let obligations = &derived.obligations; - let transport_triplet_total = obligations.service_transport_prepare_targets - + obligations.service_transport_execute_targets - + obligations.service_transport_parse_targets; - let resource_total = obligations.resource_provide_targets - + obligations.resource_acquire_targets - + obligations.resource_release_targets; - ObligationsSummary { - dry_run_completion_required: obligations.dry_run_completion_required, - transport: ObligationsTransportBucket { - execution_targets: obligations.transport_execution_targets, - triplet_prepare_targets: obligations.service_transport_prepare_targets, - triplet_execute_targets: obligations.service_transport_execute_targets, - triplet_parse_targets: obligations.service_transport_parse_targets, - triplet_total_targets: transport_triplet_total, - service_param_source_targets: obligations.service_param_source_targets, - }, - pure_node_determinism: ObligationsDeterminismBucket { - targets: obligations.pure_node_determinism_targets, - }, - resource_lifecycle: ObligationsResourceBucket { - provide_targets: obligations.resource_provide_targets, - acquire_targets: obligations.resource_acquire_targets, - release_targets: obligations.resource_release_targets, - total_targets: resource_total, - }, + unique.insert(TransportTriplet { + prepare_node: edge.from_node.0.clone(), + execute_node: edge.to_node.0.clone(), + parse_nodes, + }); } -} -pub fn render_obligations(derived: &DerivedArtifacts) -> String { - let summary = summarize_obligations(derived); - let mut out = String::new(); - out.push_str("TestObligationsSummary:\n"); - writeln!( - out, - " dry_run_completion_required: {}", - summary.dry_run_completion_required - ) - .ok(); - out.push_str(" transport:\n"); - writeln!( - out, - " execution_targets: {}", - summary.transport.execution_targets - ) - .ok(); - writeln!( - out, - " triplet_prepare_targets: {}", - summary.transport.triplet_prepare_targets - ) - .ok(); - writeln!( - out, - " triplet_execute_targets: {}", - summary.transport.triplet_execute_targets - ) - .ok(); - writeln!( - out, - " triplet_parse_targets: {}", - summary.transport.triplet_parse_targets - ) - .ok(); - writeln!( - out, - " triplet_total_targets: {}", - summary.transport.triplet_total_targets - ) - .ok(); - writeln!( - out, - " service_param_source_targets: {}", - summary.transport.service_param_source_targets - ) - .ok(); - out.push_str(" pure_node_determinism:\n"); - writeln!(out, " targets: {}", summary.pure_node_determinism.targets).ok(); - out.push_str(" resource_lifecycle:\n"); - writeln!( - out, - " provide_targets: {}", - summary.resource_lifecycle.provide_targets - ) - .ok(); - writeln!( - out, - " acquire_targets: {}", - summary.resource_lifecycle.acquire_targets - ) - .ok(); - writeln!( - out, - " release_targets: {}", - summary.resource_lifecycle.release_targets - ) - .ok(); - writeln!( - out, - " total_targets: {}", - summary.resource_lifecycle.total_targets - ) - .ok(); - out + unique.into_iter().collect() } -pub fn render_obligations_json(derived: &DerivedArtifacts) -> Result { - #[derive(Serialize)] - struct ObligationsEnvelope<'a> { - summary: &'a ObligationsSummary, - } - - let summary = summarize_obligations(derived); - serde_json::to_string_pretty(&ObligationsEnvelope { summary: &summary }) - .map_err(|error| format!("failed to serialize obligations json: {error}")) +fn node_input_port_type<'a>(node: &'a Node, port_name: &str) -> Option<&'a str> { + node.inputs + .iter() + .find(|port| port.name.0 == port_name) + .map(|port| port.type_id.0.as_str()) } -pub fn render_obligations_with_format( - derived: &DerivedArtifacts, - format: ManifestFormat, -) -> Result { - match format { - ManifestFormat::Text => Ok(render_obligations(derived)), - ManifestFormat::Json => render_obligations_json(derived), - } +fn node_output_port_type<'a>(node: &'a Node, port_name: &str) -> Option<&'a str> { + node.outputs + .iter() + .find(|port| port.name.0 == port_name) + .map(|port| port.type_id.0.as_str()) } #[cfg(test)] @@ -1409,6 +397,9 @@ pub fn render_obligations_with_format( #[allow(clippy::disallowed_methods, clippy::disallowed_types)] mod tests { use super::*; + use daglang_lower::{CallableKind, ObligationCategory}; + use gunbc_ir::{Edge, Node, Port}; + use serde_json::Value; use std::time::{SystemTime, UNIX_EPOCH}; fn unique_temp_file(name: &str) -> PathBuf { @@ -1448,7 +439,8 @@ mod tests { 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)); + let cwd = std::env::temp_dir(); + let context = build_context(&cwd, Some(&input_str)); assert_eq!(context.roots, vec![normalized_root.clone()]); assert!(context.target_file.is_none()); @@ -1473,7 +465,8 @@ mod tests { 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)); + let cwd = std::env::temp_dir(); + let context = build_context(&cwd, Some(&input_str)); assert_eq!( context.roots, vec![normalized_file @@ -1483,15 +476,166 @@ mod tests { ); assert_eq!(context.target_file, Some(normalized_file.clone())); - std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); - } + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn build_context_default_root_is_cwd_dsl() { + let cwd = std::env::temp_dir().join(format!( + "daglang_build_context_default_root_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + let context = build_context(&cwd, None); + assert_eq!(context.roots, vec![cwd.join("dsl")]); + assert!(context.target_file.is_none()); + } + + #[test] + fn build_context_treats_dag_directory_input_as_single_file_target() { + let root = std::env::temp_dir().join(format!( + "daglang_build_context_dag_dir_target_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("nested")) + .expect("failed to create .dag directory fixture"); + std::fs::write( + dag_dir.join("nested/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write nested dag fixture"); + + let input_str = dag_dir.to_string_lossy().to_string(); + let cwd = std::env::temp_dir(); + let context = build_context(&cwd, Some(&input_str)); + + assert_eq!(context.roots, vec![root.clone()]); + assert_eq!(context.target_file, Some(dag_dir.clone())); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn build_context_normalizes_trailing_slash_for_dag_directory_target() { + let root = std::env::temp_dir().join(format!( + "daglang_build_context_dag_dir_trailing_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .dag directory fixture"); + let input_with_trailing_slash = format!("{}/", dag_dir.display()); + + let cwd = std::env::temp_dir(); + let context = build_context(&cwd, Some(&input_with_trailing_slash)); + + assert_eq!(context.roots, vec![root.clone()]); + assert_eq!(context.target_file, Some(dag_dir.clone())); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn build_context_normalizes_trailing_slash_for_uppercase_dag_directory_target() { + let root = std::env::temp_dir().join(format!( + "daglang_build_context_uppercase_dag_dir_trailing_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory fixture"); + let input_with_trailing_slash = format!("{}/", dag_dir.display()); + + let cwd = std::env::temp_dir(); + let context = build_context(&cwd, Some(&input_with_trailing_slash)); + + assert_eq!(context.roots, vec![root.clone()]); + assert_eq!(context.target_file, Some(dag_dir.clone())); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn build_context_normalizes_trailing_slash_for_mixed_case_dag_directory_target() { + let root = std::env::temp_dir().join(format!( + "daglang_build_context_mixed_case_dag_dir_trailing_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory fixture"); + let input_with_trailing_slash = format!("{}/", dag_dir.display()); + + let cwd = std::env::temp_dir(); + let context = build_context(&cwd, Some(&input_with_trailing_slash)); + + assert_eq!(context.roots, vec![root.clone()]); + assert_eq!(context.target_file, Some(dag_dir.clone())); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn build_context_treats_uppercase_dag_directory_input_as_single_file_target() { + let root = std::env::temp_dir().join(format!( + "daglang_build_context_uppercase_dag_dir_target_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory fixture"); + + let input_str = dag_dir.to_string_lossy().to_string(); + let cwd = std::env::temp_dir(); + let context = build_context(&cwd, Some(&input_str)); + + assert_eq!(context.roots, vec![root.clone()]); + assert_eq!(context.target_file, Some(dag_dir.clone())); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn build_context_treats_mixed_case_dag_directory_input_as_single_file_target() { + let root = std::env::temp_dir().join(format!( + "daglang_build_context_mixed_case_dag_dir_target_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + )); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory fixture"); + + let input_str = dag_dir.to_string_lossy().to_string(); + let cwd = std::env::temp_dir(); + let context = build_context(&cwd, Some(&input_str)); - #[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()); + assert_eq!(context.roots, vec![root.clone()]); + assert_eq!(context.target_file, Some(dag_dir.clone())); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } #[test] @@ -1548,6 +692,173 @@ mod tests { assert!(rendered.contains("resource_provide_targets:")); } + #[test] + fn render_obligations_json_emits_expected_keys() { + 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"); + + let rendered = render_obligations(&output.derived, OutputFormat::Json); + let parsed: Value = + serde_json::from_str(&rendered).expect("obligations json should parse"); + assert_eq!( + parsed.get("dry_run_completion_required").and_then(Value::as_bool), + Some(true) + ); + assert!(parsed.get("transport_execution_targets").is_some()); + assert!(parsed.get("pure_node_determinism_targets").is_some()); + } + + #[test] + fn render_triplets_json_includes_makegen_transport_nodes() { + 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"); + + let rendered = render_triplets(&output.lowered_dag, OutputFormat::Json); + let parsed: Value = + serde_json::from_str(&rendered).expect("triplets json should parse"); + let triplets = parsed + .get("triplets") + .and_then(Value::as_array) + .expect("triplets should be an array"); + assert!( + triplets.iter().any(|triplet| { + triplet + .get("prepare_node") + .and_then(Value::as_str) + .is_some_and(|prepare| prepare == "prepare_read_makegen") + }), + "expected read transport triplet" + ); + assert!( + triplets.iter().any(|triplet| { + triplet + .get("prepare_node") + .and_then(Value::as_str) + .is_some_and(|prepare| prepare == "prepare_write_makegen") + }), + "expected write transport triplet" + ); + } + + #[test] + fn render_triplets_text_is_deterministic() { + 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"); + + let first = render_triplets(&output.lowered_dag, OutputFormat::Text); + let second = render_triplets(&output.lowered_dag, OutputFormat::Text); + assert_eq!(first, second, "triplet rendering should be deterministic"); + } + + #[test] + fn render_manifest_reuses_obligations_text_block() { + 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"); + + let manifest = render_manifest(&output.derived); + let obligations = render_obligations(&output.derived, OutputFormat::Text); + assert!( + manifest.ends_with(&obligations), + "manifest output should embed the same obligations text renderer" + ); + } + + #[test] + fn collect_transport_triplets_sorts_parse_nodes_and_ignores_non_transport_edges() { + let mut dag = Dag::new(); + dag.add_node(Node::opaque( + "prepare_a", + vec![], + vec![Port::scalar("request", "TransportRequest")], + LoweredOp::Callable { + module: "sample.triplets".to_string(), + kind: CallableKind::Pattern, + name: "prepare".to_string(), + obligation: ObligationCategory::None, + }, + )); + dag.add_node(Node::opaque( + "execute_a", + vec![Port::scalar("request", "TransportRequest")], + vec![Port::scalar("response", "TransportResponse")], + LoweredOp::Callable { + module: "sample.triplets".to_string(), + kind: CallableKind::Pattern, + name: "execute".to_string(), + obligation: ObligationCategory::None, + }, + )); + dag.add_node(Node::opaque( + "parse_z", + vec![Port::scalar("response", "TransportResponse")], + vec![Port::scalar("body", "String")], + LoweredOp::Callable { + module: "sample.triplets".to_string(), + kind: CallableKind::Pattern, + name: "parse_z".to_string(), + obligation: ObligationCategory::None, + }, + )); + dag.add_node(Node::opaque( + "parse_a", + vec![Port::scalar("response", "TransportResponse")], + vec![Port::scalar("body", "String")], + LoweredOp::Callable { + module: "sample.triplets".to_string(), + kind: CallableKind::Pattern, + name: "parse_a".to_string(), + obligation: ObligationCategory::None, + }, + )); + dag.add_node(Node::opaque( + "non_transport_sink", + vec![Port::scalar("value", "String")], + vec![Port::scalar("ok", "Bool")], + LoweredOp::Callable { + module: "sample.triplets".to_string(), + kind: CallableKind::Pattern, + name: "sink".to_string(), + obligation: ObligationCategory::None, + }, + )); + + dag.add_edge(Edge::new("prepare_a", "request", "execute_a", "request")); + dag.add_edge(Edge::new("execute_a", "response", "parse_z", "response")); + dag.add_edge(Edge::new("execute_a", "response", "parse_a", "response")); + dag.add_edge(Edge::new("parse_a", "body", "non_transport_sink", "value")); + + let triplets = collect_transport_triplets(&dag); + assert_eq!(triplets.len(), 1, "expected exactly one transport triplet"); + let triplet = &triplets[0]; + assert_eq!(triplet.prepare_node, "prepare_a"); + assert_eq!(triplet.execute_node, "execute_a"); + assert_eq!( + triplet.parse_nodes, + vec!["parse_a".to_string(), "parse_z".to_string()], + "parse nodes should be sorted and deterministic" + ); + } + #[test] fn compile_reports_pipeline_diagnostics_for_invalid_source() { let broken_file = unique_temp_file("broken"); @@ -4947,405 +4258,4 @@ func run(path: String) -> { body: String } { std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } - - #[test] - fn compare_compiled_makegen_against_builder_reaches_exact_parity() { - let context = makegen_context(); - let report = compare_compiled_makegen_against_builder(&context) - .expect("parity comparison should succeed"); - assert!( - report.is_exact_match(), - "compiled makegen should match builder parity after normalization: {report:?}" - ); - assert!(report.added_node_ids.is_empty()); - assert!(report.removed_node_ids.is_empty()); - assert!(report.changed_node_details.is_empty()); - assert!(report.added_edge_ids.is_empty()); - assert!(report.removed_edge_ids.is_empty()); - } - - #[test] - fn resolve_dag_maps_makegen_lowered_nodes_to_runtime_ops() { - let context = makegen_context(); - let output = compile_from_context(&context).expect("compile should succeed"); - let resolved = resolve_dag(output.lowered_dag, &OpRegistry::makegen()) - .expect("resolve should succeed"); - assert!( - resolved - .nodes - .iter() - .any(|node| node.id.0 == "tools.makegen::render_makefile" - && matches!(node.body, gunbc_ir::node::NodeBody::Opaque(ResolvedOp::RenderMakefile))) - ); - assert!( - resolved - .nodes - .iter() - .any(|node| node.id.0 == "execute_makegen_transport" - && matches!(node.body, gunbc_ir::node::NodeBody::Opaque(ResolvedOp::TransportExecute))) - ); - } - - #[test] - fn resolve_dag_preserves_makegen_edge_set() { - let context = makegen_context(); - let output = compile_from_context(&context).expect("compile should succeed"); - let lowered = output.lowered_dag.clone(); - let resolved = resolve_dag(output.lowered_dag, &OpRegistry::makegen()) - .expect("resolve should succeed"); - let lowered_edges = lowered - .edges - .iter() - .map(|edge| { - ( - edge.from_node.0.clone(), - edge.from_port.0.clone(), - edge.to_node.0.clone(), - edge.to_port.0.clone(), - ) - }) - .collect::>(); - let resolved_edges = resolved - .edges - .iter() - .map(|edge| { - ( - edge.from_node.0.clone(), - edge.from_port.0.clone(), - edge.to_node.0.clone(), - edge.to_port.0.clone(), - ) - }) - .collect::>(); - assert_eq!( - resolved_edges, lowered_edges, - "resolve_dag should preserve all lowered edge wiring" - ); - } - - #[test] - fn resolve_dag_rejects_pipeline_nodes_with_clear_error() { - let mut dag = Dag::new(); - dag.add_node(Node::opaque( - "sample.pipeline::ci", - vec![], - vec![Port::scalar("stages", "Int")], - LoweredOp::Pipeline { - module: "sample.pipeline".to_string(), - name: "ci".to_string(), - stages: 1, - }, - )); - let error = resolve_dag(dag, &OpRegistry::makegen()) - .expect_err("pipeline node should be unsupported in current resolver"); - let rendered = error.to_string(); - assert!(rendered.contains("sample.pipeline::ci")); - assert!(rendered.contains("pipeline lowering is not executable yet")); - } - - #[test] - fn compile_resolve_execute_makegen_runs_in_real_mode_with_check_mode() { - let context = makegen_context(); - let temp_path = std::env::temp_dir().join(format!( - "daglang_compiled_makegen_{}_{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - if temp_path.exists() { - std::fs::remove_file(&temp_path) - .expect("failed to clear stale check-mode output path"); - } - let input_mocks = makegen_entrypoint_mocks(&temp_path.to_string_lossy(), true); - - let log = compile_resolve_execute_from_context( - &context, - ExecutionMode::Real, - Some(&input_mocks), - ) - .expect("compiled makegen execution should succeed"); - let makegen_entry = log - .entries - .iter() - .find(|entry| entry.node_id == "tools.makegen::makegen") - .expect("makegen wrapper node should execute"); - assert!( - matches!(makegen_entry.outputs.get("written"), Some(Value::Bool(_))), - "makegen wrapper should emit a boolean `written` output" - ); - if temp_path.exists() { - std::fs::remove_file(&temp_path) - .expect("failed to clean check-mode output file created by runtime"); - } - } - - #[test] - fn compile_resolve_execute_makegen_supports_dry_run_interception() { - let context = makegen_context(); - let temp_path = std::env::temp_dir().join(format!( - "daglang_compiled_makegen_dry_run_{}_{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - let input_mocks = makegen_entrypoint_mocks(&temp_path.to_string_lossy(), false); - if temp_path.exists() { - std::fs::remove_file(&temp_path).expect("failed to remove stale dry-run output"); - } - - let dry_run_mocks = makegen_dry_run_transport_mocks(&temp_path.to_string_lossy()); - - let log = compile_resolve_execute_from_context( - &context, - ExecutionMode::DryRun(dry_run_mocks), - Some(&input_mocks), - ) - .expect("compiled makegen dry-run execution should succeed"); - assert!(log.has_intercepted(), "dry-run should intercept transport nodes"); - assert!( - !temp_path.exists(), - "dry-run mode should not create output files" - ); - } - - #[test] - fn compile_resolve_execute_makegen_check_mode_mocks_avoid_write_failures() { - let context = makegen_context(); - let blocker_path = std::env::temp_dir().join(format!( - "daglang_compiled_makegen_check_mode_blocker_{}_{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - std::fs::write(&blocker_path, "block output parent") - .expect("failed to create check-mode blocker file"); - let blocked_output_path = blocker_path.join("Makefile"); - let input_mocks = - makegen_entrypoint_mocks(blocked_output_path.to_string_lossy().as_ref(), true); - - let log = compile_resolve_execute_from_context( - &context, - ExecutionMode::DryRun(makegen_check_mode_transport_mocks( - blocked_output_path.to_string_lossy().as_ref(), - )), - Some(&input_mocks), - ) - .expect("check-mode dry-run transport should bypass real write failures"); - assert!(log.has_intercepted(), "check-mode mocks should intercept transport"); - assert!( - !blocked_output_path.exists(), - "check-mode transport mocks should not create blocked output path" - ); - - std::fs::remove_file(&blocker_path).expect("failed to remove check-mode blocker file"); - } - - #[test] - fn compile_resolve_execute_makegen_writes_output_in_real_mode() { - let context = makegen_context(); - let temp_path = std::env::temp_dir().join(format!( - "daglang_compiled_makegen_output_{}_{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - if temp_path.exists() { - std::fs::remove_file(&temp_path).expect("failed to clear previous temp output"); - } - - let input_mocks = makegen_entrypoint_mocks(&temp_path.to_string_lossy(), false); - - let log = compile_resolve_execute_from_context( - &context, - ExecutionMode::Real, - Some(&input_mocks), - ) - .expect("compiled makegen execution should succeed"); - let makegen_entry = log - .entries - .iter() - .find(|entry| entry.node_id == "tools.makegen::makegen") - .expect("makegen wrapper node should execute"); - assert_eq!(makegen_entry.outputs.get("written"), Some(&Value::Bool(true))); - - let rendered = std::fs::read_to_string(&temp_path).expect("makegen should write file"); - assert!( - rendered.contains(".PHONY: makegen"), - "generated output should contain makegen target" - ); - - std::fs::remove_file(temp_path).expect("failed to remove temp output"); - } - - #[test] - fn compile_resolve_execute_makegen_reports_write_errors() { - let context = makegen_context(); - let blocker_path = std::env::temp_dir().join(format!( - "daglang_compiled_makegen_write_blocker_{}_{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - std::fs::write(&blocker_path, "block output parent") - .expect("failed to create write blocker file"); - let blocked_output_path = blocker_path.join("Makefile"); - let input_mocks = - makegen_entrypoint_mocks(blocked_output_path.to_string_lossy().as_ref(), false); - - let error = compile_resolve_execute_from_context( - &context, - ExecutionMode::Real, - Some(&input_mocks), - ) - .expect_err("execution should fail when write target parent is a file"); - assert!( - error.contains("failed to write"), - "error should include write failure details: {error}" - ); - assert!( - error.contains(blocked_output_path.to_string_lossy().as_ref()), - "error should include the blocked output path: {error}" - ); - assert!( - !blocked_output_path.exists(), - "failed write should not create nested output path" - ); - - std::fs::remove_file(&blocker_path).expect("failed to remove write blocker file"); - } - - #[test] - fn render_viz_ascii_for_makegen_includes_nodes_edges_and_waves() { - let context = makegen_context(); - let output = compile_from_context(&context).expect("compile should succeed"); - let rendered = render_viz_ascii("daglang-compiled", &output.lowered_dag); - - assert!(rendered.contains("ASCII DAG: daglang-compiled")); - assert!(rendered.contains("Nodes:")); - assert!(rendered.contains("Edges:")); - assert!(rendered.contains("Waves:")); - assert!(rendered.contains("tools.makegen::render_makefile")); - assert!(rendered.contains("load_registry.registry -> tools.makegen::render_makefile.registry")); - } - - #[test] - fn render_viz_ascii_is_deterministic_for_makegen() { - let context = makegen_context(); - let output = compile_from_context(&context).expect("compile should succeed"); - let first = render_viz_ascii("daglang-compiled", &output.lowered_dag); - let second = render_viz_ascii("daglang-compiled", &output.lowered_dag); - - assert_eq!( - first, second, - "ascii visualization should be byte-stable across repeated renders" - ); - } - - #[test] - fn render_triplets_json_is_deterministic_for_makegen() { - let context = makegen_context(); - let output = compile_from_context(&context).expect("compile should succeed"); - let first = - render_triplets_with_format(&output.lowered_dag, ManifestFormat::Json) - .expect("triplets json should render"); - let second = - render_triplets_with_format(&output.lowered_dag, ManifestFormat::Json) - .expect("triplets json should render"); - - assert_eq!( - first, second, - "triplets json output should be byte-stable across repeated renders" - ); - assert!(first.contains("\"triplets\"")); - assert!(first.contains("\"prepare_read_makegen\"")); - assert!(first.contains("\"execute_read_makegen\"")); - assert!(first.contains("\"compare_makegen_content\"")); - } - - #[test] - fn render_obligations_json_contains_summary_buckets() { - let context = makegen_context(); - let output = compile_from_context(&context).expect("compile should succeed"); - let rendered = render_obligations_with_format(&output.derived, ManifestFormat::Json) - .expect("obligations json should render"); - let parsed: serde_json::Value = - serde_json::from_str(&rendered).expect("obligations json should parse"); - let summary = parsed - .get("summary") - .expect("summary bucket should exist"); - assert!(summary.get("dry_run_completion_required").is_some()); - assert!(summary.get("transport").is_some()); - assert!(summary.get("pure_node_determinism").is_some()); - assert!(summary.get("resource_lifecycle").is_some()); - } - - #[test] - fn summarize_obligations_computes_non_zero_triplet_totals_for_service_calls() { - let fixture = unique_temp_file("obligations_triplet_totals"); - std::fs::write( - &fixture, - r#"module sample.summary -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 obligations summary 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"); - let summary = summarize_obligations(&output.derived); - assert_eq!(summary.transport.triplet_prepare_targets, 1); - assert_eq!(summary.transport.triplet_execute_targets, 1); - assert_eq!(summary.transport.triplet_parse_targets, 1); - assert_eq!(summary.transport.triplet_total_targets, 3); - assert_eq!(summary.resource_lifecycle.acquire_targets, 1); - assert_eq!(summary.resource_lifecycle.release_targets, 1); - assert_eq!(summary.resource_lifecycle.total_targets, 2); - - std::fs::remove_file(fixture).expect("failed to clean obligations summary fixture"); - } - - fn makegen_context() -> PipelineContext { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../../dsl/tools/makegen.dag"); - let root = path - .parent() - .expect("makegen fixture should have parent") - .to_path_buf(); - PipelineContext { - roots: vec![root], - target_file: Some(path), - } - } } diff --git a/core/daglang/daglang-cli/src/main.rs b/core/daglang/daglang-cli/src/main.rs index b435b4e3ada..8b30ba57b75 100644 --- a/core/daglang/daglang-cli/src/main.rs +++ b/core/daglang/daglang-cli/src/main.rs @@ -6,292 +6,138 @@ //! //! # Commands //! -//! - `daglang viz [--format ascii|mermaid] |--self` -//! -- DAG visualization from compiled IR +//! - `daglang viz ` -- Mermaid DAG visualization from compiled IR //! - `daglang expand ` -- Show lowered GraphIR (nodes, edges, ports) //! - `daglang manifest ` -- Show derived ProgressManifest -//! - `daglang obligations ` -- Show 4-bucket TestObligations summary -//! - `daglang show-triplets ` -- Show transport triplet expansion +//! - `daglang obligations [--format text|json]` +//! -- Show derived test obligations summary +//! - `daglang show-triplets [--format text|json]` +//! -- Show transport triplet expansions //! - `daglang modules [dir]` -- Show the discovered module graph //! - `daglang check ` -- Parse + typecheck without lowering //! - `daglang compile ` -- Full compilation pipeline -//! - `daglang run ` -- Compile + resolve + execute makegen DAG -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use daglang_cli::compile::{ - build_context, compile_from_context, compile_resolve_execute_from_context, - makegen_check_mode_transport_mocks, makegen_dry_run_transport_mocks, makegen_entrypoint_mocks, render_expand, - render_manifest_with_format, render_obligations_with_format, render_triplets_with_format, - render_viz_ascii, ManifestFormat, + build_context, compile_from_context, render_expand, render_manifest, render_obligations, + render_triplets, CompileOutput, OutputFormat, }; use daglang_cli::path_utils; -use daglang_cli::pipeline::{build_pipeline_dag, run_pipeline, PipelineContext, PipelineStop}; -use gunbc_exec::ExecutionMode; +use daglang_cli::pipeline::{ + build_pipeline_dag, run_pipeline, PipelineContext, PipelineResult, PipelineStop, +}; fn main() { let args: Vec = std::env::args().collect(); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); if args.len() < 2 { eprintln!("Usage: daglang [args...]"); eprintln!(); eprintln!("Commands:"); - eprintln!(" viz [--format ascii|mermaid] |--self DAG visualization"); + eprintln!(" viz Mermaid DAG visualization"); eprintln!(" expand Show lowered GraphIR (nodes/edges/ports)"); - eprintln!(" manifest [--format text|json] Show derived ProgressManifest"); - eprintln!(" obligations [--format text|json] Show 4-bucket test obligations"); - eprintln!(" show-triplets [--format text|json] Show transport triplet expansion"); + eprintln!(" manifest Show derived ProgressManifest"); + eprintln!(" obligations [--format text|json]"); + eprintln!(" Show derived test obligations summary"); + eprintln!(" show-triplets [--format text|json]"); + eprintln!(" Show transport triplet expansions"); eprintln!(" modules [dir] Show discovered module graph"); eprintln!(" check Parse + typecheck (no lowering)"); eprintln!(" compile Full compilation pipeline"); - eprintln!(" run [--output ] [--dry-run|--check-mode]"); std::process::exit(1); } match args[1].as_str() { "viz" => { - let viz_args = match parse_viz_args(&args) { - Ok(values) => values, - Err(error) => { - eprintln!("{error}"); - exit_usage("viz [--format ascii|mermaid] |--self"); - } - }; - match viz_args.target { - VizTarget::SelfGraph => { + match args.len() { + 2 => exit_usage("viz |viz --self"), + 3 if args[2] == "--self" => { let dag = build_pipeline_dag(); - match viz_args.format { - VizFormat::Ascii => { - println!("{}", render_viz_ascii("daglang-compiler-pipeline", &dag)) - } - VizFormat::Mermaid => { - println!("{}", dag.to_mermaid("daglang-compiler-pipeline")) - } - } + println!("{}", dag.to_mermaid("daglang-compiler-pipeline")); } - VizTarget::File(file) => { - let context = build_context(Some(&file)); - match compile_from_context(&context) { - Ok(output) => match viz_args.format { - VizFormat::Ascii => { - println!("{}", render_viz_ascii("daglang-compiled", &output.lowered_dag)) - } - VizFormat::Mermaid => { - println!("{}", output.lowered_dag.to_mermaid("daglang-compiled")) - } - }, - Err(error) => { - eprintln!("{error}"); - std::process::exit(1); - } - } + 3 => { + let output = compile_target_or_exit(&cwd, args.get(2)); + println!("{}", output.lowered_dag.to_mermaid("daglang-compiled")); } + _ => exit_usage("viz |viz --self"), } } "expand" => { if args.len() != 3 { exit_usage("expand "); } - 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); - } - } + let output = compile_target_or_exit(&cwd, args.get(2)); + println!("{}", render_expand(&output.lowered_dag)); } "manifest" => { - let (manifest_path, manifest_format) = match parse_manifest_args(&args) { - Ok(values) => values, - Err(error) => { - eprintln!("{error}"); - exit_usage("manifest [--format text|json] "); - } - }; - let context = build_context(Some(&manifest_path)); - match compile_from_context(&context) { - Ok(output) => { - match render_manifest_with_format(&output.derived, manifest_format) { - Ok(rendered) => println!("{rendered}"), - Err(error) => { - eprintln!("{error}"); - std::process::exit(1); - } - } - } - Err(error) => { - eprintln!("{error}"); - std::process::exit(1); - } + if args.len() != 3 { + exit_usage("manifest "); } + let output = compile_target_or_exit(&cwd, args.get(2)); + println!("{}", render_manifest(&output.derived)); } "obligations" => { - let (obligations_path, obligations_format) = match parse_obligations_args(&args) { - Ok(values) => values, - Err(error) => { - eprintln!("{error}"); - exit_usage("obligations [--format text|json] "); - } - }; - let context = build_context(Some(&obligations_path)); - match compile_from_context(&context) { - Ok(output) => { - match render_obligations_with_format(&output.derived, obligations_format) { - Ok(rendered) => println!("{rendered}"), - Err(error) => { - eprintln!("{error}"); - std::process::exit(1); - } - } - } - Err(error) => { - eprintln!("{error}"); - std::process::exit(1); - } + if args.len() != 3 && args.len() != 5 { + exit_usage("obligations [--format text|json]"); } + let format = parse_output_format("obligations", &args) + .unwrap_or_else(|usage| exit_usage(&usage)); + let output = compile_target_or_exit(&cwd, args.get(2)); + println!("{}", render_obligations(&output.derived, format)); } "show-triplets" => { - let (triplets_path, triplets_format) = match parse_show_triplets_args(&args) { - Ok(values) => values, - Err(error) => { - eprintln!("{error}"); - exit_usage("show-triplets [--format text|json] "); - } - }; - let context = build_context(Some(&triplets_path)); - match compile_from_context(&context) { - Ok(output) => match render_triplets_with_format(&output.lowered_dag, triplets_format) - { - Ok(rendered) => println!("{rendered}"), - Err(error) => { - eprintln!("{error}"); - std::process::exit(1); - } - }, - Err(error) => { - eprintln!("{error}"); - std::process::exit(1); - } + if args.len() != 3 && args.len() != 5 { + exit_usage("show-triplets [--format text|json]"); } + let format = parse_output_format("show-triplets", &args) + .unwrap_or_else(|usage| exit_usage(&usage)); + let output = compile_target_or_exit(&cwd, args.get(2)); + println!("{}", render_triplets(&output.lowered_dag, format)); } "modules" => { if args.len() > 3 { exit_usage("modules [dir]"); } - let roots = vec![resolve_root(args.get(2))]; + let roots = vec![resolve_root(&cwd, args.get(2))]; let context = PipelineContext { roots, target_file: None, }; - match run_pipeline(&context, PipelineStop::Report) { - Ok(result) => { - if let Some(report) = result.report { - println!("{report}"); - } - } - Err(error) => { - eprintln!("pipeline error: {error}"); - std::process::exit(1); - } + let result = run_pipeline_or_exit(&context, PipelineStop::Report); + if let Some(report) = result.report() { + println!("{report}"); } } "check" => { if args.len() > 3 { exit_usage("check "); } - let input = args.get(2).map(|value| path_utils::normalize_cli_path(PathBuf::from(value))); - let (roots, target_file) = match input { - Some(path) if path_utils::has_dag_extension(&path) && !path.is_dir() => - { - 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_root(None)], None), - }; - - let context = PipelineContext { roots, target_file }; - match run_pipeline(&context, PipelineStop::Build) { - Ok(result) => { - if result.diagnostics.is_empty() { - println!("OK: checked {} file(s)", result.parsed_count); - } else { - for diagnostic in &result.diagnostics { - eprintln!("{diagnostic}"); - } - std::process::exit(1); - } - } - Err(error) => { - eprintln!("pipeline error: {error}"); - std::process::exit(1); + let context = build_check_pipeline_context(&cwd, args.get(2)); + let result = run_pipeline_or_exit(&context, PipelineStop::Build); + if result.diagnostics().is_empty() { + println!("OK: checked {} file(s)", result.parsed_count()); + } else { + for diagnostic in result.diagnostics() { + eprintln!("{diagnostic}"); } + std::process::exit(1); } } "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); - } - } - } - "run" => { - let run_args = match parse_run_args(&args) { - Ok(values) => values, - Err(error) => { - eprintln!("{error}"); - exit_usage("run [--output ] [--dry-run|--check-mode]"); - } - }; - let context = build_context(Some(&run_args.file)); - let input_mocks = makegen_entrypoint_mocks(&run_args.output_path, run_args.check_mode); - let mode = if run_args.dry_run { - ExecutionMode::DryRun(makegen_dry_run_transport_mocks(&run_args.output_path)) - } else if run_args.check_mode { - ExecutionMode::DryRun(makegen_check_mode_transport_mocks(&run_args.output_path)) - } else { - ExecutionMode::Real - }; - match compile_resolve_execute_from_context(&context, mode, Some(&input_mocks)) { - Ok(log) => { - let written = if run_args.dry_run || run_args.check_mode { - false - } else { - run_written_from_log(&log) - }; - println!( - "Run completed: nodes={}, output={}, written={written}, mode={}", - log.entries.len(), - run_args.output_path, - run_mode_label(&run_args) - ); - } - Err(error) => { - eprintln!("{error}"); - std::process::exit(1); - } + let output = compile_target_or_exit(&cwd, args.get(2)); + 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); } } cmd => { @@ -301,305 +147,70 @@ fn main() { } } -fn run_mode_label(run_args: &RunArgs) -> &'static str { - if run_args.dry_run { - return "dry-run"; - } - if run_args.check_mode { - return "check-mode"; - } - "real" -} - -fn run_written_from_log(log: &gunbc_exec::ExecutionLog) -> bool { - for transport_entry in log - .entries - .iter() - .rev() - .filter(|entry| entry.node_id == "execute_makegen_transport") - { - if transport_entry.was_intercepted { - return false; - } - if let Some(skip) = transport_entry - .outputs - .get("skip") - .and_then(|value| value.as_bool()) - { - return !skip; - } - } - for wrapper_entry in log - .entries - .iter() - .rev() - .filter(|entry| entry.node_id == "tools.makegen::makegen") - { - if let Some(written) = wrapper_entry - .outputs - .get("written") - .and_then(|value| value.as_bool()) - { - return written; - } - } - false -} - -fn resolve_root(arg: Option<&String>) -> PathBuf { +fn resolve_root(cwd: &std::path::Path, arg: Option<&String>) -> PathBuf { if let Some(path) = arg { - return path_utils::normalize_cli_path(PathBuf::from(path)); + return path_utils::normalize_cli_path(cwd, &PathBuf::from(path)); } - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - path_utils::default_root_from_cwd(&cwd) + path_utils::resolve_default_root(cwd) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VizFormat { - Ascii, - Mermaid, -} - -impl VizFormat { - fn parse(value: &str) -> Option { - match value { - "ascii" => Some(Self::Ascii), - "mermaid" => Some(Self::Mermaid), - _ => None, +/// Builds check pipeline context from CLI input. +/// +/// Unlike compile context construction, `.dag`-suffixed paths that resolve to +/// directories stay in directory mode for `daglang check`. +fn build_check_pipeline_context( + cwd: &std::path::Path, + input: Option<&String>, +) -> PipelineContext { + let normalized_input = input + .map(|value| path_utils::normalize_cli_path(cwd, &PathBuf::from(value))); + let (roots, target_file) = match normalized_input { + Some(path) if path_utils::has_dag_extension(&path) && !path.is_dir() => { + let root = path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| cwd.to_path_buf()); + (vec![root], Some(path)) } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum VizTarget { - SelfGraph, - File(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct VizArgs { - target: VizTarget, - format: VizFormat, + Some(path) => (vec![path], None), + None => (vec![resolve_root(cwd, None)], None), + }; + PipelineContext { roots, target_file } } -fn parse_viz_args(args: &[String]) -> Result { - let mut format = VizFormat::Ascii; - let mut target = None::; - let mut index = 2usize; - while index < args.len() { - let arg = args[index].as_str(); - match arg { - "--self" => { - if target.is_some() { - return Err("viz accepts either --self or , not both".to_string()); - } - target = Some(VizTarget::SelfGraph); - index += 1; - } - "--format" => { - let value = args - .get(index + 1) - .ok_or_else(|| "--format requires a value: ascii or mermaid".to_string())?; - format = VizFormat::parse(value) - .ok_or_else(|| format!("unknown viz format `{value}`"))?; - index += 2; - } - _ if arg.starts_with("--format=") => { - let value = arg - .strip_prefix("--format=") - .expect("prefix should already be checked"); - format = VizFormat::parse(value) - .ok_or_else(|| format!("unknown viz format `{value}`"))?; - index += 1; - } - _ if arg.starts_with("--") => return Err(format!("unknown viz flag `{arg}`")), - _ => { - if let Some(existing_target) = &target { - return Err(match existing_target { - VizTarget::SelfGraph => { - "viz accepts either --self or , not both".to_string() - } - VizTarget::File(_) => "viz takes exactly one input path".to_string(), - }); - } - target = Some(VizTarget::File(args[index].clone())); - index += 1; - } +fn compile_target_or_exit(cwd: &std::path::Path, input: Option<&String>) -> CompileOutput { + let context = build_context(cwd, input); + match compile_from_context(&context) { + Ok(output) => output, + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); } } - - let target = target.ok_or_else(|| "viz requires --self or ".to_string())?; - Ok(VizArgs { target, format }) } -fn parse_single_file_with_optional_format( - args: &[String], - command: &str, -) -> Result<(String, ManifestFormat), String> { - let mut format = ManifestFormat::Text; - let mut path = None::; - let mut index = 2usize; - while index < args.len() { - let arg = args[index].as_str(); - if arg == "--format" { - let value = args - .get(index + 1) - .ok_or_else(|| "--format requires a value: text or json".to_string())?; - format = ManifestFormat::parse(value) - .ok_or_else(|| format!("unknown {command} format `{value}`"))?; - index += 2; - continue; - } - if let Some(value) = arg.strip_prefix("--format=") { - format = ManifestFormat::parse(value) - .ok_or_else(|| format!("unknown {command} format `{value}`"))?; - index += 1; - continue; +fn run_pipeline_or_exit(context: &PipelineContext, stop: PipelineStop) -> PipelineResult { + match run_pipeline(context, stop) { + Ok(result) => result, + Err(error) => { + eprintln!("pipeline error: {error}"); + std::process::exit(1); } - if arg.starts_with("--") { - return Err(format!("unknown {command} flag `{arg}`")); - } - if path.is_some() { - return Err(format!("{command} takes exactly one input path")); - } - path = Some(args[index].clone()); - index += 1; } - - let path = path.ok_or_else(|| format!("{command} requires "))?; - Ok((path, format)) -} - -fn parse_manifest_args(args: &[String]) -> Result<(String, ManifestFormat), String> { - parse_single_file_with_optional_format(args, "manifest") -} - -fn parse_obligations_args(args: &[String]) -> Result<(String, ManifestFormat), String> { - parse_single_file_with_optional_format(args, "obligations") -} - -fn parse_show_triplets_args(args: &[String]) -> Result<(String, ManifestFormat), String> { - parse_single_file_with_optional_format(args, "show-triplets") -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RunArgs { - file: String, - output_path: String, - dry_run: bool, - check_mode: bool, } -fn parse_run_args(args: &[String]) -> Result { - let mut file = None::; - let mut output_path = "Makefile".to_string(); - let mut has_output_path = false; - let mut dry_run = false; - let mut has_dry_run_flag = false; - let mut check_mode = false; - let mut has_check_mode_flag = false; - let mut parse_flags = true; - let mut index = 2usize; - while index < args.len() { - let arg = args[index].as_str(); - if parse_flags { - match arg { - "--" => { - parse_flags = false; - index += 1; - } - "--dry-run" => { - if has_dry_run_flag { - return Err("run accepts --dry-run at most once".to_string()); - } - dry_run = true; - has_dry_run_flag = true; - index += 1; - } - "--check-mode" => { - if has_check_mode_flag { - return Err("run accepts --check-mode at most once".to_string()); - } - check_mode = true; - has_check_mode_flag = true; - index += 1; - } - "--output" => { - if has_output_path { - return Err("run accepts at most one --output path".to_string()); - } - let value = args - .get(index + 1) - .ok_or_else(|| "--output requires a path".to_string())?; - if value == "--" { - let escaped_value = args - .get(index + 2) - .ok_or_else(|| "--output requires a non-empty path".to_string())?; - if escaped_value.is_empty() { - return Err("--output requires a non-empty path".to_string()); - } - output_path = escaped_value.clone(); - has_output_path = true; - index += 3; - continue; - } - if value.is_empty() || value.starts_with("--") { - return Err("--output requires a non-empty path".to_string()); - } - output_path = value.clone(); - has_output_path = true; - index += 2; - } - _ if arg.starts_with("--output=") => { - if has_output_path { - return Err("run accepts at most one --output path".to_string()); - } - let value = arg - .strip_prefix("--output=") - .expect("prefix just checked") - .to_string(); - if value.is_empty() { - return Err("--output requires a non-empty path".to_string()); - } - output_path = value; - has_output_path = true; - index += 1; - } - _ if arg.starts_with("--") => return Err(format!("unknown run flag `{arg}`")), - _ => { - if file.is_some() { - return Err("run takes exactly one input".to_string()); - } - file = Some(args[index].clone()); - index += 1; - } - } - } else { - if file.is_some() { - return Err("run takes exactly one input".to_string()); - } - file = Some(args[index].clone()); - index += 1; - } - } - - let file = file.ok_or_else(|| "run requires ".to_string())?; - let file_path = Path::new(&file); - if !path_utils::has_dag_extension(file_path) { - return Err("run requires a .dag input file".to_string()); +fn parse_output_format(command: &str, args: &[String]) -> Result { + if args.len() == 3 { + return Ok(OutputFormat::Text); } - if file_path.is_dir() { - return Err("run requires a .dag input file, not a directory".to_string()); + if args.len() != 5 || args[3] != "--format" { + return Err(format!("{command} [--format text|json]")); } - if dry_run && check_mode { - return Err("--dry-run and --check-mode cannot be used together".to_string()); + match args[4].as_str() { + "text" => Ok(OutputFormat::Text), + "json" => Ok(OutputFormat::Json), + _ => Err(format!("{command} [--format text|json]")), } - Ok(RunArgs { - file, - output_path, - dry_run, - check_mode, - }) } fn exit_usage(command: &str) -> ! { @@ -610,22 +221,26 @@ fn exit_usage(command: &str) -> ! { #[cfg(test)] mod tests { - use super::{ - parse_manifest_args, parse_obligations_args, parse_run_args, parse_show_triplets_args, - parse_viz_args, run_mode_label, run_written_from_log, RunArgs, VizArgs, VizFormat, - VizTarget, - }; - use crate::path_utils::{default_root_from_cwd, has_dag_extension, normalize_path_components}; - use daglang_cli::compile::ManifestFormat; - use gunbc_exec::{ExecutionLog, LogEntry}; - use gunbc_ir::Value; - use std::collections::HashMap; + use crate::path_utils::{has_dag_extension, normalize_path_components, resolve_default_root}; use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; fn root_path() -> PathBuf { PathBuf::from(Path::new(std::path::MAIN_SEPARATOR_STR)) } + fn unique_temp_dir(label: &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_main_{label}_{}_{}", + std::process::id(), + nanos + )) + } + #[test] fn normalize_path_components_collapses_curdir_and_parent_segments() { let path = root_path().join("workspace").join(".").join("core").join("..").join("dsl"); @@ -710,21 +325,21 @@ mod tests { } #[test] - fn default_root_from_cwd_normalizes_curdir_suffix() { + fn resolve_default_root_normalizes_curdir_suffix() { let cwd = root_path().join("workspace").join("project").join("."); - let normalized_root = default_root_from_cwd(&cwd); + let normalized_root = resolve_default_root(&cwd); let expected = root_path().join("workspace").join("project").join("dsl"); assert_eq!(normalized_root, expected); } #[test] - fn default_root_from_cwd_collapses_parent_segments() { + fn resolve_default_root_collapses_parent_segments() { let cwd = root_path() .join("workspace") .join("project") .join("nested") .join(".."); - let normalized_root = default_root_from_cwd(&cwd); + let normalized_root = resolve_default_root(&cwd); let expected = root_path().join("workspace").join("project").join("dsl"); assert_eq!(normalized_root, expected); } @@ -773,1622 +388,193 @@ mod tests { } #[test] - fn parse_manifest_args_supports_default_text_format() { + fn parse_output_format_defaults_to_text_for_three_args() { let args = vec![ "daglang".to_string(), - "manifest".to_string(), + "obligations".to_string(), "dsl/tools/makegen.dag".to_string(), ]; - let (path, format) = parse_manifest_args(&args).expect("parse should succeed"); - assert_eq!(path, "dsl/tools/makegen.dag"); - assert_eq!(format, ManifestFormat::Text); + let format = super::parse_output_format("obligations", &args) + .expect("three-argument form should parse"); + assert!(matches!(format, super::OutputFormat::Text)); } #[test] - fn parse_manifest_args_supports_json_format_flag() { - let args = vec![ + fn parse_output_format_accepts_json_and_text_flags() { + let json_args = vec![ "daglang".to_string(), - "manifest".to_string(), + "obligations".to_string(), + "dsl/tools/makegen.dag".to_string(), "--format".to_string(), "json".to_string(), + ]; + let text_args = vec![ + "daglang".to_string(), + "show-triplets".to_string(), "dsl/tools/makegen.dag".to_string(), + "--format".to_string(), + "text".to_string(), ]; - let (path, format) = parse_manifest_args(&args).expect("parse should succeed"); - assert_eq!(path, "dsl/tools/makegen.dag"); - assert_eq!(format, ManifestFormat::Json); - } - #[test] - fn parse_manifest_args_requires_a_path() { - let args = vec!["daglang".to_string(), "manifest".to_string()]; - let error = parse_manifest_args(&args).expect_err("parse should fail"); - assert!(error.contains("requires ")); + let json_format = super::parse_output_format("obligations", &json_args) + .expect("json format should parse"); + let text_format = super::parse_output_format("show-triplets", &text_args) + .expect("text format should parse"); + + assert!(matches!(json_format, super::OutputFormat::Json)); + assert!(matches!(text_format, super::OutputFormat::Text)); } #[test] - fn parse_obligations_args_supports_json_equals_syntax() { - let args = vec![ + fn parse_output_format_rejects_invalid_shapes() { + let missing_value = vec![ "daglang".to_string(), "obligations".to_string(), - "--format=json".to_string(), "dsl/tools/makegen.dag".to_string(), + "--format".to_string(), ]; - let (path, format) = parse_obligations_args(&args).expect("parse should succeed"); - assert_eq!(path, "dsl/tools/makegen.dag"); - assert_eq!(format, ManifestFormat::Json); - } - - #[test] - fn parse_show_triplets_args_rejects_unknown_flags() { - let args = vec![ + let bad_flag = vec![ "daglang".to_string(), - "show-triplets".to_string(), - "--mystery".to_string(), + "obligations".to_string(), "dsl/tools/makegen.dag".to_string(), + "--fmt".to_string(), + "json".to_string(), ]; - let error = parse_show_triplets_args(&args).expect_err("parse should fail"); - assert!(error.contains("unknown show-triplets flag")); - } - - #[test] - fn parse_viz_args_defaults_to_ascii_for_file_target() { - let args = vec![ + let bad_value = vec![ "daglang".to_string(), - "viz".to_string(), + "show-triplets".to_string(), "dsl/tools/makegen.dag".to_string(), - ]; - let parsed = parse_viz_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - VizArgs { - target: VizTarget::File("dsl/tools/makegen.dag".to_string()), - format: VizFormat::Ascii, - } - ); - } - - #[test] - fn parse_viz_args_supports_mermaid_self_graph() { - let args = vec![ - "daglang".to_string(), - "viz".to_string(), "--format".to_string(), - "mermaid".to_string(), - "--self".to_string(), + "yaml".to_string(), ]; - let parsed = parse_viz_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - VizArgs { - target: VizTarget::SelfGraph, - format: VizFormat::Mermaid, - } - ); - } - - #[test] - fn parse_viz_args_supports_equals_ascii_format_for_file_target() { - let args = vec![ + let bad_case_variant_value = vec![ "daglang".to_string(), - "viz".to_string(), - "--format=ascii".to_string(), + "obligations".to_string(), "dsl/tools/makegen.dag".to_string(), + "--format".to_string(), + "JSON".to_string(), ]; - let parsed = parse_viz_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - VizArgs { - target: VizTarget::File("dsl/tools/makegen.dag".to_string()), - format: VizFormat::Ascii, - } - ); - } - - #[test] - fn parse_viz_args_rejects_self_and_file_combination() { - let args = vec![ + let bad_title_case_value = vec![ "daglang".to_string(), - "viz".to_string(), - "--self".to_string(), + "show-triplets".to_string(), "dsl/tools/makegen.dag".to_string(), + "--format".to_string(), + "Json".to_string(), ]; - let error = parse_viz_args(&args).expect_err("parse should fail"); - assert!(error.contains("either --self or ")); - } - #[test] - fn parse_run_args_supports_defaults_and_positional_file() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); + let expected_usage = "obligations [--format text|json]"; assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "Makefile".to_string(), - dry_run: false, - check_mode: false, - } + super::parse_output_format("obligations", &missing_value), + Err(expected_usage.to_string()) ); - } - - #[test] - fn parse_run_args_supports_output_and_dry_run_flags() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "out/Generated.mk".to_string(), - "--dry-run".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "out/Generated.mk".to_string(), - dry_run: true, - check_mode: false, - } + super::parse_output_format("obligations", &bad_flag), + Err(expected_usage.to_string()) ); - } - - #[test] - fn parse_run_args_supports_output_and_check_mode_flags_before_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "out/check_mode_before_input.mk".to_string(), - "--check-mode".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "out/check_mode_before_input.mk".to_string(), - dry_run: false, - check_mode: true, - } + super::parse_output_format("show-triplets", &bad_value), + Err("show-triplets [--format text|json]".to_string()) ); - } - - #[test] - fn parse_run_args_supports_flags_after_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--dry-run".to_string(), - "--output".to_string(), - "out/ordered_after_input.mk".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "out/ordered_after_input.mk".to_string(), - dry_run: true, - check_mode: false, - } + super::parse_output_format("obligations", &bad_case_variant_value), + Err(expected_usage.to_string()) ); - } - - #[test] - fn parse_run_args_supports_check_mode_flags_after_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--check-mode".to_string(), - "--output".to_string(), - "out/check_mode_after_input.mk".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "out/check_mode_after_input.mk".to_string(), - dry_run: false, - check_mode: true, - } + super::parse_output_format("show-triplets", &bad_title_case_value), + Err("show-triplets [--format text|json]".to_string()) ); } #[test] - fn parse_run_args_supports_check_mode_equals_output_after_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--check-mode".to_string(), - "--output=out/check_mode_equals_after_input.mk".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); + fn build_check_pipeline_context_defaults_to_cwd_dsl_root_without_input() { + let cwd = root_path().join("workspace").join("project").join("."); + let context = super::build_check_pipeline_context(&cwd, None); + assert_eq!(context.target_file, None); assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "out/check_mode_equals_after_input.mk".to_string(), - dry_run: false, - check_mode: true, - } + context.roots, + vec![root_path().join("workspace").join("project").join("dsl")] ); } #[test] - fn parse_run_args_supports_dry_run_equals_output_after_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--dry-run".to_string(), - "--output=out/dry_run_equals_after_input.mk".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); + fn build_check_pipeline_context_treats_dag_file_as_target_input() { + let cwd = root_path().join("workspace").join("project"); + let input = "nested/tool.dag".to_string(); + let context = super::build_check_pipeline_context(&cwd, Some(&input)); assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "out/dry_run_equals_after_input.mk".to_string(), - dry_run: true, - check_mode: false, - } + context.target_file, + Some(cwd.join("nested").join("tool.dag")) ); + assert_eq!(context.roots, vec![cwd.join("nested")]); } #[test] - fn parse_run_args_supports_check_mode_equals_dash_prefixed_output_after_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--check-mode".to_string(), - "--output=--check-mode.mk".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "--check-mode.mk".to_string(), - dry_run: false, - check_mode: true, - } - ); - } + fn build_check_pipeline_context_treats_dag_named_directory_as_root() { + let root = unique_temp_dir("check_context_dag_dir"); + let cwd = root.join("workspace"); + let dag_dir = cwd.join("bundle.dag"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .dag directory fixture"); + let input = "bundle.dag".to_string(); - #[test] - fn parse_run_args_supports_check_mode_equals_dash_prefixed_output_before_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--check-mode".to_string(), - "--output=--check-mode-before-input.mk".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "--check-mode-before-input.mk".to_string(), - dry_run: false, - check_mode: true, - } - ); - } + let context = super::build_check_pipeline_context(&cwd, Some(&input)); - #[test] - fn parse_run_args_accepts_equals_output_path_with_dash_prefix() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output=--generated.mk".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "--generated.mk".to_string(), - dry_run: false, - check_mode: false, - } - ); - } + assert_eq!(context.target_file, None); + assert_eq!(context.roots, vec![dag_dir.clone()]); - #[test] - fn parse_run_args_rejects_conflicting_dry_run_and_check_mode_flags() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--dry-run".to_string(), - "--check-mode".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("--dry-run and --check-mode cannot be used together"), - "expected clear mode conflict error, got: {error}" - ); + std::fs::remove_dir_all(&root).expect("failed to cleanup temp fixture"); } #[test] - fn parse_run_args_rejects_conflicting_modes_when_flags_follow_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--dry-run".to_string(), - "--check-mode".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("--dry-run and --check-mode cannot be used together"), - "expected clear mode conflict error with post-input flags, got: {error}" - ); - } + fn build_check_pipeline_context_treats_uppercase_dag_named_directory_as_root() { + let root = unique_temp_dir("check_context_uppercase_dag_dir"); + let cwd = root.join("workspace"); + let dag_dir = cwd.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory fixture"); + let input = "bundle.DAG".to_string(); - #[test] - fn parse_run_args_rejects_conflicting_modes_with_double_dash_input() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--dry-run".to_string(), - "--check-mode".to_string(), - "--".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("--dry-run and --check-mode cannot be used together"), - "expected clear mode conflict error with -- separator input, got: {error}" - ); + let context = super::build_check_pipeline_context(&cwd, Some(&input)); + + assert_eq!(context.target_file, None); + assert_eq!(context.roots, vec![dag_dir.clone()]); + + std::fs::remove_dir_all(&root).expect("failed to cleanup temp fixture"); } #[test] - fn parse_run_args_rejects_duplicate_dry_run_flags() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--dry-run".to_string(), - "--dry-run".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts --dry-run at most once"), - "expected duplicate dry-run flag error, got: {error}" - ); + fn build_check_pipeline_context_treats_mixed_case_dag_named_directory_with_trailing_slash_as_root( + ) { + let root = unique_temp_dir("check_context_mixed_case_dag_dir"); + let cwd = root.join("workspace"); + let dag_dir = cwd.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory fixture"); + let input = "bundle.DaG/".to_string(); + + let context = super::build_check_pipeline_context(&cwd, Some(&input)); + + assert_eq!(context.target_file, None); + assert_eq!(context.roots, vec![dag_dir.clone()]); + + std::fs::remove_dir_all(&root).expect("failed to cleanup temp fixture"); } #[test] - fn parse_run_args_rejects_duplicate_dry_run_flags_after_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--dry-run".to_string(), - "--dry-run".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts --dry-run at most once"), - "expected duplicate dry-run flag error after input path, got: {error}" - ); + fn resolve_root_defaults_to_cwd_dsl_when_arg_missing() { + let cwd = root_path().join("workspace").join("project").join("."); + let resolved = super::resolve_root(&cwd, None); + let expected = root_path().join("workspace").join("project").join("dsl"); + assert_eq!(resolved, expected); } #[test] - fn parse_run_args_rejects_duplicate_check_mode_flags() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--check-mode".to_string(), - "--check-mode".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts --check-mode at most once"), - "expected duplicate check-mode flag error, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_check_mode_flags_after_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--check-mode".to_string(), - "--check-mode".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts --check-mode at most once"), - "expected duplicate check-mode flag error after input path, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_non_dag_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run requires a .dag input file"), - "expected clear non-dag input error, got: {error}" - ); - } - - #[test] - fn parse_run_args_accepts_uppercase_dag_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.DAG".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!(parsed.file, "dsl/tools/makegen.DAG"); - } - - #[test] - fn parse_run_args_rejects_directory_input_even_with_dag_extension() { - let temp_dir = std::env::temp_dir().join(format!( - "daglang_parse_run_directory_input_{}.dag", - 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(&temp_dir).expect("failed to create directory fixture"); - let args = vec![ - "daglang".to_string(), - "run".to_string(), - temp_dir.to_string_lossy().to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run requires a .dag input file, not a directory"), - "expected directory input validation error, got: {error}" - ); - std::fs::remove_dir_all(&temp_dir).expect("failed to remove directory fixture"); - } - - #[test] - fn parse_run_args_rejects_directory_input_even_with_dag_extension_after_double_dash() { - let temp_dir = std::env::temp_dir().join(format!( - "daglang_parse_run_directory_input_after_double_dash_{}.dag", - 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(&temp_dir).expect("failed to create directory fixture"); - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--".to_string(), - temp_dir.to_string_lossy().to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run requires a .dag input file, not a directory"), - "expected directory input validation after --, got: {error}" - ); - std::fs::remove_dir_all(&temp_dir).expect("failed to remove directory fixture"); - } - - #[test] - fn run_mode_label_distinguishes_real_dry_run_and_check_mode() { - let real = RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "Makefile".to_string(), - dry_run: false, - check_mode: false, - }; - let dry_run = RunArgs { - dry_run: true, - ..real.clone() - }; - let check_mode = RunArgs { - check_mode: true, - ..real.clone() - }; - assert_eq!(run_mode_label(&real), "real"); - assert_eq!(run_mode_label(&dry_run), "dry-run"); - assert_eq!(run_mode_label(&check_mode), "check-mode"); - } - - #[test] - fn parse_run_args_rejects_unknown_flags() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--mystery".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!(error.contains("unknown run flag")); - } - - #[test] - fn parse_run_args_rejects_unknown_flags_after_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--mystery".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!(error.contains("unknown run flag")); - } - - #[test] - fn parse_run_args_supports_double_dash_for_dash_prefixed_file_paths() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--".to_string(), - "--fixture.dag".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!(parsed.file, "--fixture.dag"); - assert!(!parsed.dry_run); - assert!(!parsed.check_mode); - } - - #[test] - fn parse_run_args_supports_double_dash_for_uppercase_dag_input() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--".to_string(), - "dsl/tools/makegen.DAG".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!(parsed.file, "dsl/tools/makegen.DAG"); - } - - #[test] - fn parse_run_args_supports_double_dash_for_dash_prefixed_uppercase_dag_input() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--".to_string(), - "--fixture.DAG".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!(parsed.file, "--fixture.DAG"); - } - - #[test] - fn parse_run_args_supports_check_mode_and_equals_output_with_double_dash_input() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--check-mode".to_string(), - "--output=out/check_mode_double_dash_input.mk".to_string(), - "--".to_string(), - "--fixture.DAG".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "--fixture.DAG".to_string(), - output_path: "out/check_mode_double_dash_input.mk".to_string(), - dry_run: false, - check_mode: true, - } - ); - } - - #[test] - fn parse_run_args_supports_dry_run_and_equals_output_with_double_dash_input() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--dry-run".to_string(), - "--output=out/dry_run_double_dash_input.mk".to_string(), - "--".to_string(), - "--fixture.DAG".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "--fixture.DAG".to_string(), - output_path: "out/dry_run_double_dash_input.mk".to_string(), - dry_run: true, - check_mode: false, - } - ); - } - - #[test] - fn parse_run_args_supports_real_mode_equals_output_with_double_dash_input() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output=out/real_mode_double_dash_input.mk".to_string(), - "--".to_string(), - "--fixture.DAG".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "--fixture.DAG".to_string(), - output_path: "out/real_mode_double_dash_input.mk".to_string(), - dry_run: false, - check_mode: false, - } - ); - } - - #[test] - fn parse_run_args_supports_check_mode_split_output_with_double_dash_input() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--check-mode".to_string(), - "--output".to_string(), - "out/check_mode_split_double_dash_input.mk".to_string(), - "--".to_string(), - "--fixture.DAG".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "--fixture.DAG".to_string(), - output_path: "out/check_mode_split_double_dash_input.mk".to_string(), - dry_run: false, - check_mode: true, - } - ); - } - - #[test] - fn parse_run_args_supports_dry_run_split_output_with_double_dash_input() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--dry-run".to_string(), - "--output".to_string(), - "out/dry_run_split_double_dash_input.mk".to_string(), - "--".to_string(), - "--fixture.DAG".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "--fixture.DAG".to_string(), - output_path: "out/dry_run_split_double_dash_input.mk".to_string(), - dry_run: true, - check_mode: false, - } - ); - } - - #[test] - fn parse_run_args_supports_real_mode_split_output_with_double_dash_input() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "out/real_mode_split_double_dash_input.mk".to_string(), - "--".to_string(), - "--fixture.DAG".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "--fixture.DAG".to_string(), - output_path: "out/real_mode_split_double_dash_input.mk".to_string(), - dry_run: false, - check_mode: false, - } - ); - } - - #[test] - fn parse_run_args_rejects_double_dash_without_input() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run requires "), - "expected missing input error after double dash, got: {error}" - ); - } - - #[test] - fn parse_run_args_treats_flag_like_token_after_double_dash_as_input() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--".to_string(), - "--dry-run".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run requires a .dag input file"), - "expected non-dag input validation for flag-like token after --, got: {error}" - ); - } - - #[test] - fn parse_run_args_treats_output_equals_token_after_double_dash_as_input() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--".to_string(), - "--output=generated.mk".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run requires a .dag input file"), - "expected non-dag input validation for output-like token after --, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_multiple_inputs_after_double_dash() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--".to_string(), - "dsl/tools/makegen.dag".to_string(), - "dsl/tools/other.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run takes exactly one input"), - "expected single-input constraint after double dash, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_flag_like_second_input_after_double_dash() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--dry-run".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run takes exactly one input"), - "expected single-input constraint for flag-like second positional token, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_output_equals_token_as_second_input_after_double_dash() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--output=alt.mk".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run takes exactly one input"), - "expected single-input constraint for output-looking second positional token, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_output_equals_first_token_plus_second_input_after_double_dash() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--".to_string(), - "--output=alt.mk".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run takes exactly one input"), - "expected single-input constraint when output-like token is first positional after --, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_missing_output_path_value() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "--dry-run".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("--output requires a non-empty path"), - "expected clear missing-path output flag error, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_empty_equals_output_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output=".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("--output requires a non-empty path"), - "expected clear empty output path error, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_split_output_flags() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "out/first.mk".to_string(), - "--output".to_string(), - "out/second.mk".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts at most one --output path"), - "expected duplicate output-flag error for split syntax, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_mixed_output_flag_syntax() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output=out/first.mk".to_string(), - "--output".to_string(), - "out/second.mk".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts at most one --output path"), - "expected duplicate output-flag error for mixed syntax, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_equals_output_flags() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output=out/first.mk".to_string(), - "--output=out/second.mk".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts at most one --output path"), - "expected duplicate output-flag error for equals syntax, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_split_output_flags_after_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--output".to_string(), - "out/first.mk".to_string(), - "--output".to_string(), - "out/second.mk".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts at most one --output path"), - "expected duplicate output-flag error after input path for split syntax, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_equals_output_flags_after_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--output=out/first.mk".to_string(), - "--output=out/second.mk".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts at most one --output path"), - "expected duplicate output-flag error after input path for equals syntax, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_mixed_output_flags_after_input_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--output=out/first.mk".to_string(), - "--output".to_string(), - "out/second.mk".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts at most one --output path"), - "expected duplicate output-flag error after input path for mixed syntax, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_split_output_separator_after_equals_output() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output=out/first.mk".to_string(), - "--output".to_string(), - "--".to_string(), - "--dash-prefixed-second.mk".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts at most one --output path"), - "expected duplicate output-flag error for equals + split-separator syntax, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_split_output_separator_after_equals_output_after_input_path() - { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "dsl/tools/makegen.dag".to_string(), - "--output=out/first.mk".to_string(), - "--output".to_string(), - "--".to_string(), - "--dash-prefixed-second.mk".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts at most one --output path"), - "expected duplicate output-flag error for post-input equals + split-separator syntax, got: {error}" - ); - } - - #[test] - fn parse_run_args_supports_split_output_with_dash_prefixed_path_via_separator() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--dry-run".to_string(), - "--output".to_string(), - "--".to_string(), - "--dash-prefixed-output.mk".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "--dash-prefixed-output.mk".to_string(), - dry_run: true, - check_mode: false, - } - ); - } - - #[test] - fn parse_run_args_supports_check_mode_split_output_with_dash_prefixed_path_via_separator() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--check-mode".to_string(), - "--output".to_string(), - "--".to_string(), - "--dash-prefixed-output.mk".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "--dash-prefixed-output.mk".to_string(), - dry_run: false, - check_mode: true, - } - ); - } - - #[test] - fn parse_run_args_supports_split_output_separator_with_mode_flag_after_escaped_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "--".to_string(), - "--dash-prefixed-output.mk".to_string(), - "--check-mode".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "--dash-prefixed-output.mk".to_string(), - dry_run: false, - check_mode: true, - } - ); - } - - #[test] - fn parse_run_args_supports_split_output_separator_with_dry_run_flag_after_escaped_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "--".to_string(), - "--dash-prefixed-output.mk".to_string(), - "--dry-run".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "--dash-prefixed-output.mk".to_string(), - dry_run: true, - check_mode: false, - } - ); - } - - #[test] - fn parse_run_args_rejects_conflicting_modes_across_split_output_separator_segments() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--dry-run".to_string(), - "--output".to_string(), - "--".to_string(), - "--dash-prefixed-output.mk".to_string(), - "--check-mode".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("--dry-run and --check-mode cannot be used together"), - "expected mode conflict across split-output separator segments, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_output_flag_after_split_output_separator_escape() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "--".to_string(), - "--dash-prefixed-output.mk".to_string(), - "--output".to_string(), - "out/second.mk".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts at most one --output path"), - "expected duplicate output flag error after split-output separator escape, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_equals_output_flag_after_split_output_separator_escape() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "--".to_string(), - "--dash-prefixed-output.mk".to_string(), - "--output=out/second.mk".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts at most one --output path"), - "expected duplicate equals output flag error after split-output separator escape, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_dry_run_flags_across_split_output_separator_segments() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "--".to_string(), - "--dash-prefixed-output.mk".to_string(), - "--dry-run".to_string(), - "--dry-run".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts --dry-run at most once"), - "expected duplicate dry-run flag error across split-output separator segments, got: {error}" - ); - } - - #[test] - fn parse_run_args_rejects_duplicate_check_mode_flags_across_split_output_separator_segments() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "--".to_string(), - "--dash-prefixed-output.mk".to_string(), - "--check-mode".to_string(), - "--check-mode".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("run accepts --check-mode at most once"), - "expected duplicate check-mode flag error across split-output separator segments, got: {error}" - ); - } - - #[test] - fn parse_run_args_supports_split_output_separator_with_flag_like_output_value() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "--".to_string(), - "--check-mode".to_string(), - "--dry-run".to_string(), - "dsl/tools/makegen.dag".to_string(), - ]; - let parsed = parse_run_args(&args).expect("parse should succeed"); - assert_eq!( - parsed, - RunArgs { - file: "dsl/tools/makegen.dag".to_string(), - output_path: "--check-mode".to_string(), - dry_run: true, - check_mode: false, - } - ); - } - - #[test] - fn parse_run_args_rejects_split_output_separator_without_path() { - let args = vec![ - "daglang".to_string(), - "run".to_string(), - "--output".to_string(), - "--".to_string(), - ]; - let error = parse_run_args(&args).expect_err("parse should fail"); - assert!( - error.contains("--output requires a non-empty path"), - "expected missing escaped output path error, got: {error}" - ); - } - - #[test] - fn run_written_from_log_prefers_transport_skip_signal() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - ], - }; - assert!( - !run_written_from_log(&log), - "transport skip should force written=false regardless of wrapper value" - ); - } - - #[test] - fn run_written_from_log_falls_back_to_wrapper_output_when_transport_missing() { - let log = ExecutionLog { - entries: vec![LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(true))]), - was_intercepted: false, - }], - }; - assert!( - run_written_from_log(&log), - "wrapper written value should be used when transport node is absent" - ); - } - - #[test] - fn run_written_from_log_prefers_latest_wrapper_signal_when_transport_missing() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(false))]), - was_intercepted: false, - }, - ], - }; - assert!( - !run_written_from_log(&log), - "latest wrapper written=false should override earlier wrapper written=true values" - ); - } - - #[test] - fn run_written_from_log_uses_earlier_wrapper_boolean_when_latest_wrapper_non_boolean() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([( - "written".to_string(), - Value::Str("false".to_string()), - )]), - was_intercepted: false, - }, - ], - }; - assert!( - run_written_from_log(&log), - "non-boolean latest wrapper written values should be ignored in favor of earlier boolean signals" - ); - } - - #[test] - fn run_written_from_log_uses_earlier_wrapper_boolean_when_latest_wrapper_missing_written() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("status".to_string(), Value::Str("ok".to_string()))]), - was_intercepted: false, - }, - ], - }; - assert!( - run_written_from_log(&log), - "latest wrapper entries without a written boolean should fall back to earlier wrapper boolean signals" - ); - } - - #[test] - fn run_written_from_log_returns_false_when_wrapper_signals_are_non_authoritative() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([( - "written".to_string(), - Value::Str("unknown".to_string()), - )]), - was_intercepted: false, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("status".to_string(), Value::Str("ok".to_string()))]), - was_intercepted: false, - }, - ], - }; - assert!( - !run_written_from_log(&log), - "wrapper fallback should default to written=false when no wrapper entry has an authoritative boolean written signal" - ); - } - - #[test] - fn run_written_from_log_treats_intercepted_transport_as_not_written() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([( - "makegen_response".to_string(), - Value::Response(gunbc_ir::transport::TransportResponse::File( - gunbc_ir::transport::FileResponse::written("dry-run.mk"), - )), - )]), - was_intercepted: true, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - ], - }; - assert!( - !run_written_from_log(&log), - "intercepted transport should force written=false in run summary" - ); - } - - #[test] - fn run_written_from_log_prefers_latest_transport_entry() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Bool(false))]), - was_intercepted: false, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(false))]), - was_intercepted: false, - }, - ], - }; - assert!( - run_written_from_log(&log), - "latest transport skip=false should win over earlier skip=true values" - ); - } - - #[test] - fn run_written_from_log_latest_transport_skip_true_overrides_earlier_false() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Bool(false))]), - was_intercepted: false, - }, - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - ], - }; - assert!( - !run_written_from_log(&log), - "latest transport skip=true should win over earlier skip=false values" - ); - } - - #[test] - fn run_written_from_log_latest_intercepted_transport_overrides_earlier_write_signal() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Bool(false))]), - was_intercepted: false, - }, - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Bool(false))]), - was_intercepted: true, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - ], - }; - assert!( - !run_written_from_log(&log), - "latest intercepted transport should force written=false" - ); - } - - #[test] - fn run_written_from_log_latest_skip_false_overrides_earlier_intercepted_transport() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Bool(false))]), - was_intercepted: true, - }, - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Bool(false))]), - was_intercepted: false, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(false))]), - was_intercepted: false, - }, - ], - }; - assert!( - run_written_from_log(&log), - "latest non-intercepted skip=false transport should override older intercepted entries" - ); - } - - #[test] - fn run_written_from_log_uses_earlier_transport_skip_when_latest_skip_missing() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("status".to_string(), Value::Str("ok".to_string()))]), - was_intercepted: false, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - ], - }; - assert!( - !run_written_from_log(&log), - "latest transport entries without skip should fall back to earlier skip signals" - ); - } - - #[test] - fn run_written_from_log_ignores_non_boolean_skip_and_uses_earlier_boolean_signal() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Bool(false))]), - was_intercepted: false, - }, - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Str("true".to_string()))]), - was_intercepted: false, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(false))]), - was_intercepted: false, - }, - ], - }; - assert!( - run_written_from_log(&log), - "non-boolean skip values should be ignored in favor of older boolean skip signals" - ); - } - - #[test] - fn run_written_from_log_falls_back_to_wrapper_when_transport_signals_are_non_authoritative() { - let log = ExecutionLog { - entries: vec![ - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("status".to_string(), Value::Str("ok".to_string()))]), - was_intercepted: false, - }, - LogEntry { - node_id: "execute_makegen_transport".to_string(), - inputs: None, - outputs: HashMap::from([("skip".to_string(), Value::Str("false".to_string()))]), - was_intercepted: false, - }, - LogEntry { - node_id: "tools.makegen::makegen".to_string(), - inputs: None, - outputs: HashMap::from([("written".to_string(), Value::Bool(true))]), - was_intercepted: false, - }, - ], - }; - assert!( - run_written_from_log(&log), - "when transport entries do not expose authoritative skip/intercept signals, wrapper written value should be used" - ); + fn resolve_root_normalizes_relative_arg_against_cwd() { + let cwd = root_path().join("workspace").join("project"); + let arg = "dsl/./tools/../services".to_string(); + let resolved = super::resolve_root(&cwd, Some(&arg)); + let expected = root_path() + .join("workspace") + .join("project") + .join("dsl") + .join("services"); + assert_eq!(resolved, expected); } - } diff --git a/core/daglang/daglang-cli/src/path_utils.rs b/core/daglang/daglang-cli/src/path_utils.rs index a04296e1862..ff122d53263 100644 --- a/core/daglang/daglang-cli/src/path_utils.rs +++ b/core/daglang/daglang-cli/src/path_utils.rs @@ -2,25 +2,19 @@ use std::path::{Component, Path, PathBuf}; pub use daglang_resolve::has_dag_extension; -pub fn resolve_default_root() -> PathBuf { - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - default_root_from_cwd(&cwd) +pub fn resolve_default_root(cwd: &Path) -> PathBuf { + normalize_path_components(&cwd.join("dsl")) } -pub fn normalize_cli_path(path: PathBuf) -> PathBuf { +pub fn normalize_cli_path(cwd: &Path, path: &Path) -> PathBuf { let absolute = if path.is_absolute() { - path + path.to_path_buf() } else { - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); cwd.join(path) }; normalize_path_components(&absolute) } -pub fn default_root_from_cwd(cwd: &Path) -> PathBuf { - normalize_path_components(&cwd.join("dsl")) -} - pub fn normalize_path_components(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { diff --git a/core/daglang/daglang-cli/src/pipeline.rs b/core/daglang/daglang-cli/src/pipeline.rs index 0c6b09be0b3..8cff8249c8c 100644 --- a/core/daglang/daglang-cli/src/pipeline.rs +++ b/core/daglang/daglang-cli/src/pipeline.rs @@ -43,11 +43,104 @@ pub enum PipelineStop { } #[derive(Debug)] -pub struct PipelineResult { - pub diagnostics: Vec, - pub parsed_count: usize, - pub module_graph: Option, - pub report: Option, +pub enum PipelineResult { + Parse { + diagnostics: Vec, + parsed_count: usize, + }, + Build { + diagnostics: Vec, + parsed_count: usize, + module_graph: ModuleGraph, + }, + Report { + diagnostics: Vec, + parsed_count: usize, + module_graph: ModuleGraph, + report: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PipelineStage { + Parse, + Build, + Report, +} + +impl PipelineResult { + fn parsed(diagnostics: Vec, parsed_count: usize) -> Self { + Self::Parse { + diagnostics, + parsed_count, + } + } + + fn built( + diagnostics: Vec, + parsed_count: usize, + module_graph: ModuleGraph, + ) -> Self { + Self::Build { + diagnostics, + parsed_count, + module_graph, + } + } + + fn reported( + diagnostics: Vec, + parsed_count: usize, + module_graph: ModuleGraph, + report: String, + ) -> Self { + Self::Report { + diagnostics, + parsed_count, + module_graph, + report, + } + } + + pub fn stage(&self) -> PipelineStage { + match self { + Self::Parse { .. } => PipelineStage::Parse, + Self::Build { .. } => PipelineStage::Build, + Self::Report { .. } => PipelineStage::Report, + } + } + + pub fn diagnostics(&self) -> &[Diagnostic] { + match self { + Self::Parse { diagnostics, .. } + | Self::Build { diagnostics, .. } + | Self::Report { diagnostics, .. } => diagnostics, + } + } + + pub fn parsed_count(&self) -> usize { + match self { + Self::Parse { parsed_count, .. } + | Self::Build { parsed_count, .. } + | Self::Report { parsed_count, .. } => *parsed_count, + } + } + + pub fn module_graph(&self) -> Option<&ModuleGraph> { + match self { + Self::Build { module_graph, .. } | Self::Report { module_graph, .. } => { + Some(module_graph) + } + Self::Parse { .. } => None, + } + } + + pub fn report(&self) -> Option<&str> { + match self { + Self::Report { report, .. } => Some(report.as_str()), + Self::Parse { .. } | Self::Build { .. } => None, + } + } } #[derive(Debug)] @@ -64,15 +157,6 @@ pub struct ParsedModule { pub ast: SourceFile, } -#[derive(Debug)] -enum PipeValue { - Files(Vec), - ParsedModules(Vec), - ModuleGraph(ModuleGraph), - Diagnostics(Vec), - Report(String), -} - pub fn build_pipeline_dag() -> Dag { let mut dag = Dag::new(); @@ -164,163 +248,80 @@ pub fn build_pipeline_dag() -> Dag { pub fn run_pipeline(context: &PipelineContext, stop: PipelineStop) -> Result { let dag = build_pipeline_dag(); validate_pipeline_semantics(&dag)?; - let topo = topological_order(&dag)?; - let mut values: HashMap = HashMap::new(); - let mut parsed_count = 0usize; - - for node_id in topo { - let node = dag - .get_node(&node_id.as_str().into()) - .ok_or_else(|| format!("missing node in pipeline DAG: {node_id}"))?; - let mut inputs = HashMap::new(); - - for edge in dag.edges.iter().filter(|edge| edge.to_node.0 == node_id) { - let key = edge_key(&edge.from_node.0, &edge.from_port.0); - let value = values - .remove(&key) - .ok_or_else(|| format!("missing pipeline value for edge {key}"))?; - inputs.insert(edge.to_port.0.clone(), value); - } + topological_order(&dag)?; - let outputs = execute_op(&node.body, context, inputs)?; - if node_id == NODE_PARSE { - if let Some(PipeValue::ParsedModules(parsed)) = outputs.get(PORT_PARSED_MODULES) { - parsed_count = parsed.len(); - } - } - for (port, value) in outputs { - values.insert(edge_key(&node_id, &port), value); - } + let files = discover_files(context)?; + let (parsed_modules, parse_diagnostics) = parse_files(files, &context.roots); + let parsed_count = parsed_modules.len(); - match (&stop, node_id.as_str()) { - (PipelineStop::Parse, NODE_PARSE) - | (PipelineStop::Build, NODE_BUILD) - | (PipelineStop::Report, NODE_REPORT) => break, - _ => {} - } + if matches!(stop, PipelineStop::Parse) { + return Ok(PipelineResult::parsed(parse_diagnostics, parsed_count)); } - let diagnostics = take_diagnostics(&mut values)?; - let parsed_count_from_values = match values.remove(&edge_key(NODE_PARSE, PORT_PARSED_MODULES)) { - Some(PipeValue::ParsedModules(parsed)) => parsed.len(), - _ => 0, - }; - let parsed_count = parsed_count.max(parsed_count_from_values); - let module_graph = match values.remove(&edge_key(NODE_BUILD, PORT_MODULE_GRAPH)) { - Some(PipeValue::ModuleGraph(graph)) => Some(graph), - _ => None, - }; - let report = match values.remove(&edge_key(NODE_REPORT, PORT_REPORT)) { - Some(PipeValue::Report(report)) => Some(report), - _ => None, - }; + let mut diagnostics = parse_diagnostics; + let graph = build_module_graph(parsed_modules, &mut diagnostics); + let diagnostics = diagnostic::normalize_diagnostics(diagnostics); + if matches!(stop, PipelineStop::Build) { + return Ok(PipelineResult::built(diagnostics, parsed_count, graph)); + } - Ok(PipelineResult { - diagnostics: diagnostic::normalize_diagnostics(diagnostics), + let report = format_module_report(&graph, &diagnostics); + Ok(PipelineResult::reported( + diagnostics, parsed_count, - module_graph, + graph, report, - }) + )) } -fn execute_op( - body: &gunbc_ir::node::NodeBody, - context: &PipelineContext, - mut inputs: HashMap, -) -> Result, String> { - let op = match body { - gunbc_ir::node::NodeBody::Opaque(op) => op, - gunbc_ir::node::NodeBody::SubDag(_) => return Err("compiler pipeline uses opaque ops only".into()), - }; - - match op { - CompilerOp::DiscoverFiles => { - let files = discover_files(context)?; - Ok(HashMap::from([ - (PORT_FILES.to_string(), PipeValue::Files(files)), - (PORT_DIAGNOSTICS.to_string(), PipeValue::Diagnostics(Vec::new())), - ])) - } - CompilerOp::ParseAll => { - let files = take_files(&mut inputs)?; - let mut diagnostics = take_diagnostics_from_inputs(&mut inputs); - let mut parsed = Vec::new(); - let canonical_roots = daglang_resolve::canonicalize_roots(&context.roots); - - for file in files { - match parser::parse_with_file_diagnostics(&file.path, &file.source) { - Ok(ast) => { - let module_path = ast - .module_path - .as_ref() - .map(|module| module.node.segments.clone()) - .unwrap_or_else(|| { - daglang_resolve::path_to_module_path(&file.path, &context.roots, &canonical_roots) - }); - let imports = ast - .imports - .iter() - .map(|import| import.node.path.segments.clone()) - .collect(); - parsed.push(ParsedModule { - path: file.path, - module_path, - imports, - ast, - }); - } - Err(file_diagnostics) => { - diagnostics.extend(file_diagnostics); - } - } +fn parse_files(files: Vec, roots: &[PathBuf]) -> (Vec, Vec) { + let mut diagnostics = Vec::new(); + let mut parsed_modules = Vec::new(); + let canonical_roots = daglang_resolve::canonicalize_roots(roots); + + for file in files { + match parser::parse_with_file_diagnostics(&file.path, &file.source) { + Ok(ast) => { + let module_path = ast + .module_path + .as_ref() + .map(|module| module.node.segments.clone()) + .unwrap_or_else(|| { + daglang_resolve::path_to_module_path(&file.path, roots, &canonical_roots) + }); + let imports = ast + .imports + .iter() + .map(|import| import.node.path.segments.clone()) + .collect(); + parsed_modules.push(ParsedModule { + path: file.path, + module_path, + imports, + ast, + }); } - - Ok(HashMap::from([ - ( - PORT_PARSED_MODULES.to_string(), - PipeValue::ParsedModules(parsed), - ), - ( - PORT_DIAGNOSTICS.to_string(), - PipeValue::Diagnostics(diagnostic::normalize_diagnostics(diagnostics)), - ), - ])) + Err(file_diagnostics) => diagnostics.extend(file_diagnostics), } - CompilerOp::BuildModuleGraph => { - let parsed_modules = take_parsed_modules(&mut inputs)?; - let mut diagnostics = take_diagnostics_from_inputs(&mut inputs); - let graph = build_module_graph(parsed_modules, &mut diagnostics); + } - Ok(HashMap::from([ - (PORT_MODULE_GRAPH.to_string(), PipeValue::ModuleGraph(graph)), - ( - PORT_DIAGNOSTICS.to_string(), - PipeValue::Diagnostics(diagnostic::normalize_diagnostics(diagnostics)), - ), - ])) - } - CompilerOp::ReportModules => { - let graph = take_module_graph(&mut inputs)?; - let diagnostics = diagnostic::normalize_diagnostics(take_diagnostics_from_inputs(&mut inputs)); - let mut report = String::new(); - report.push_str("Discovered modules:\n"); - report.push_str(&graph.display_tree()); - if !diagnostics.is_empty() { - report.push_str("\nDiagnostics:\n"); - for diagnostic in &diagnostics { - report.push_str(&format!(" {}\n", diagnostic.render())); - } - } + ( + parsed_modules, + diagnostic::normalize_diagnostics(diagnostics), + ) +} - Ok(HashMap::from([ - (PORT_REPORT.to_string(), PipeValue::Report(report)), - ( - PORT_DIAGNOSTICS.to_string(), - PipeValue::Diagnostics(diagnostics), - ), - ])) +fn format_module_report(graph: &ModuleGraph, diagnostics: &[Diagnostic]) -> String { + let mut report = String::new(); + report.push_str("Discovered modules:\n"); + report.push_str(&graph.display_tree()); + if !diagnostics.is_empty() { + report.push_str("\nDiagnostics:\n"); + for diagnostic in diagnostics { + report.push_str(&format!(" {}\n", diagnostic.render())); } } + report } // Compiler pipeline: discovers and reads .dag source files @@ -733,66 +734,6 @@ fn validate_pipeline_semantics(dag: &Dag) -> Result<(), String> { Ok(()) } -fn take_files(inputs: &mut HashMap) -> Result, String> { - match inputs.remove(PORT_FILES) { - Some(PipeValue::Files(files)) => Ok(files), - Some(other) => Err(format!("expected files input, found {other:?}")), - None => Err("missing files input".into()), - } -} - -fn take_parsed_modules(inputs: &mut HashMap) -> Result, String> { - match inputs.remove(PORT_PARSED_MODULES) { - Some(PipeValue::ParsedModules(parsed)) => Ok(parsed), - Some(other) => Err(format!("expected parsed modules input, found {other:?}")), - None => Err("missing parsed modules input".into()), - } -} - -fn take_module_graph(inputs: &mut HashMap) -> Result { - match inputs.remove(PORT_MODULE_GRAPH) { - Some(PipeValue::ModuleGraph(graph)) => Ok(graph), - Some(other) => Err(format!("expected module graph input, found {other:?}")), - None => Err("missing module graph input".into()), - } -} - -fn take_diagnostics_from_inputs(inputs: &mut HashMap) -> Vec { - match inputs.remove(PORT_DIAGNOSTICS) { - Some(PipeValue::Diagnostics(diagnostics)) => diagnostics, - _ => Vec::new(), - } -} - -fn take_diagnostics(values: &mut HashMap) -> Result, String> { - if let Some(PipeValue::Diagnostics(diagnostics)) = - values.remove(&edge_key(NODE_REPORT, PORT_DIAGNOSTICS)) - { - return Ok(diagnostics); - } - if let Some(PipeValue::Diagnostics(diagnostics)) = - values.remove(&edge_key(NODE_BUILD, PORT_DIAGNOSTICS)) - { - return Ok(diagnostics); - } - if let Some(PipeValue::Diagnostics(diagnostics)) = - values.remove(&edge_key(NODE_PARSE, PORT_DIAGNOSTICS)) - { - return Ok(diagnostics); - } - if let Some(PipeValue::Diagnostics(diagnostics)) = - values.remove(&edge_key(NODE_DISCOVER, PORT_DIAGNOSTICS)) - { - return Ok(diagnostics); - } - Err("missing diagnostics output in pipeline".into()) -} - -fn edge_key(node: &str, port: &str) -> String { - format!("{node}.{port}") -} - - #[cfg(test)] // Test infrastructure: filesystem access for test fixtures #[allow(clippy::disallowed_methods, clippy::disallowed_types)] @@ -873,56 +814,9 @@ mod tests { fn run_parse_and_build_ops(context: &PipelineContext) -> (ModuleGraph, Vec) { let files = discover_files(context).expect("discovery should succeed"); - let parse_outputs = execute_op( - &gunbc_ir::node::NodeBody::Opaque(CompilerOp::ParseAll), - context, - HashMap::from([ - (PORT_FILES.to_string(), PipeValue::Files(files)), - ( - PORT_DIAGNOSTICS.to_string(), - PipeValue::Diagnostics(Vec::new()), - ), - ]), - ) - .expect("parse op should succeed"); - - let mut parse_outputs = parse_outputs; - let parsed_modules = match parse_outputs.remove(PORT_PARSED_MODULES) { - Some(PipeValue::ParsedModules(parsed_modules)) => parsed_modules, - other => panic!("expected parsed modules output, got {other:?}"), - }; - let parse_diagnostics = match parse_outputs.remove(PORT_DIAGNOSTICS) { - Some(PipeValue::Diagnostics(diagnostics)) => diagnostics, - other => panic!("expected parse diagnostics output, got {other:?}"), - }; - - let build_outputs = execute_op( - &gunbc_ir::node::NodeBody::Opaque(CompilerOp::BuildModuleGraph), - context, - HashMap::from([ - ( - PORT_PARSED_MODULES.to_string(), - PipeValue::ParsedModules(parsed_modules), - ), - ( - PORT_DIAGNOSTICS.to_string(), - PipeValue::Diagnostics(parse_diagnostics), - ), - ]), - ) - .expect("build op should succeed"); - - let mut build_outputs = build_outputs; - let graph = match build_outputs.remove(PORT_MODULE_GRAPH) { - Some(PipeValue::ModuleGraph(graph)) => graph, - other => panic!("expected module graph output, got {other:?}"), - }; - let diagnostics = match build_outputs.remove(PORT_DIAGNOSTICS) { - Some(PipeValue::Diagnostics(diagnostics)) => diagnostics, - other => panic!("expected build diagnostics output, got {other:?}"), - }; - - (graph, diagnostics) + let (parsed_modules, mut diagnostics) = parse_files(files, &context.roots); + let graph = build_module_graph(parsed_modules, &mut diagnostics); + (graph, diagnostic::normalize_diagnostics(diagnostics)) } #[test] @@ -1105,90 +999,6 @@ mod tests { ); } - #[test] - fn parse_op_rejects_wrong_pipe_value_variant() { - let context = PipelineContext { - roots: vec![], - target_file: None, - }; - let mut inputs = HashMap::new(); - inputs.insert( - PORT_FILES.to_string(), - PipeValue::Diagnostics(vec![Diagnostic::new( - DiagnosticKind::Pipeline, - "not files", - )]), - ); - inputs.insert( - PORT_DIAGNOSTICS.to_string(), - PipeValue::Diagnostics(Vec::new()), - ); - - let err = execute_op( - &gunbc_ir::node::NodeBody::Opaque(CompilerOp::ParseAll), - &context, - inputs, - ) - .expect_err("expected parse op to reject wrong input variant"); - assert!(err.contains("expected files input")); - } - - #[test] - fn build_op_rejects_wrong_pipe_value_variant() { - let context = PipelineContext { - roots: vec![], - target_file: None, - }; - let mut inputs = HashMap::new(); - inputs.insert( - PORT_PARSED_MODULES.to_string(), - PipeValue::Diagnostics(vec![Diagnostic::new( - DiagnosticKind::Pipeline, - "not parsed modules", - )]), - ); - inputs.insert( - PORT_DIAGNOSTICS.to_string(), - PipeValue::Diagnostics(Vec::new()), - ); - - let err = execute_op( - &gunbc_ir::node::NodeBody::Opaque(CompilerOp::BuildModuleGraph), - &context, - inputs, - ) - .expect_err("expected build op to reject wrong input variant"); - assert!(err.contains("expected parsed modules input")); - } - - #[test] - fn report_op_rejects_wrong_pipe_value_variant() { - let context = PipelineContext { - roots: vec![], - target_file: None, - }; - let mut inputs = HashMap::new(); - inputs.insert( - PORT_MODULE_GRAPH.to_string(), - PipeValue::Diagnostics(vec![Diagnostic::new( - DiagnosticKind::Pipeline, - "not module graph", - )]), - ); - inputs.insert( - PORT_DIAGNOSTICS.to_string(), - PipeValue::Diagnostics(Vec::new()), - ); - - let err = execute_op( - &gunbc_ir::node::NodeBody::Opaque(CompilerOp::ReportModules), - &context, - inputs, - ) - .expect_err("expected report op to reject wrong input variant"); - assert!(err.contains("expected module graph input")); - } - #[test] fn parse_pipeline_collects_diagnostics_for_invalid_source() { let root = unique_temp_dir("invalid"); @@ -1202,12 +1012,12 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); assert!( - !result.diagnostics.is_empty(), + !result.diagnostics().is_empty(), "invalid source should produce diagnostics" ); assert!( result - .diagnostics + .diagnostics() .iter() .any(|diag| diag.render().contains("broken.dag:2:")), "diagnostics should contain file:line:col locations" @@ -1228,14 +1038,14 @@ mod tests { ) .expect("pipeline should execute"); - assert_eq!(result.parsed_count, 42); + assert_eq!(result.parsed_count(), 42); assert!( - result.diagnostics.is_empty(), + result.diagnostics().is_empty(), "real corpus parse stop should not emit parse diagnostics: {:?}", - result.diagnostics + result.diagnostics() ); - assert!(result.report.is_none()); - assert!(result.module_graph.is_none()); + assert!(result.report().is_none()); + assert!(result.module_graph().is_none()); } #[test] @@ -1251,11 +1061,11 @@ mod tests { .expect("pipeline should execute"); assert!( - result.diagnostics.is_empty(), + result.diagnostics().is_empty(), "real corpus report should emit no resolve diagnostics, got: {:?}", - result.diagnostics + result.diagnostics() ); - let report = result.report.as_ref().expect("report should be available"); + let report = result.report().expect("report should be available"); assert!(report.contains("Discovered modules:")); assert!(report.contains("tools.makegen")); } @@ -1272,7 +1082,7 @@ mod tests { ) .expect("pipeline should execute"); assert_eq!( - result.parsed_count, 42, + result.parsed_count(), 42, "report stop should retain parse-stage file count for real corpus" ); } @@ -1288,7 +1098,7 @@ mod tests { PipelineStop::Report, ) .expect("pipeline should execute"); - let report = result.report.as_ref().expect("report should be available"); + let report = result.report().expect("report should be available"); let actual = reported_modules_in_order(report); let expected: Vec = expected_real_corpus_module_order() .into_iter() @@ -1441,10 +1251,40 @@ mod tests { target_file: None, }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); - assert_eq!(result.parsed_count, 1); - assert!(result.module_graph.is_none()); - assert!(result.report.is_none()); - assert!(result.diagnostics.is_empty()); + assert!( + matches!(result, PipelineResult::Parse { .. }), + "parse stop should return parse variant" + ); + assert_eq!(result.stage(), PipelineStage::Parse); + assert_eq!(result.parsed_count(), 1); + assert!(result.module_graph().is_none()); + assert!(result.report().is_none()); + assert!(result.diagnostics().is_empty()); + + fs::remove_dir_all(root).expect("failed to cleanup temp root"); + } + + #[test] + fn build_stop_emits_module_graph_without_report() { + let root = unique_temp_dir("build_stop_outputs"); + fs::create_dir_all(&root).expect("failed to create temp root"); + fs::write(root.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write source"); + + let context = PipelineContext { + roots: vec![root.clone()], + target_file: None, + }; + let result = run_pipeline(&context, PipelineStop::Build).expect("pipeline should execute"); + assert!( + matches!(result, PipelineResult::Build { .. }), + "build stop should return build variant" + ); + assert_eq!(result.stage(), PipelineStage::Build); + assert_eq!(result.parsed_count(), 1); + assert!(result.module_graph().is_some()); + assert!(result.report().is_none()); + assert!(result.diagnostics().is_empty()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -1461,18 +1301,25 @@ mod tests { target_file: None, }; let result = run_pipeline(&context, PipelineStop::Report).expect("pipeline should execute"); + assert!( + matches!(result, PipelineResult::Report { .. }), + "report stop should return report variant" + ); assert_eq!( - result.parsed_count, 1, + result.parsed_count(), 1, "report stop should retain parsed count from parse stage" ); + assert_eq!(result.stage(), PipelineStage::Report); assert!( - result.module_graph.is_none(), - "report stop currently consumes module graph into report stage" + result.module_graph().is_some(), + "report stop should preserve module graph for downstream callers" ); - let report = result.report.as_ref().expect("report stop should include report text"); + let report = result + .report() + .expect("report stop should include report text"); assert!(report.contains("Discovered modules:")); assert!(report.contains("sample.main")); - assert!(result.diagnostics.is_empty()); + assert!(result.diagnostics().is_empty()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -1494,20 +1341,20 @@ mod tests { assert!( result - .diagnostics + .diagnostics() .iter() .any(|diag| diag.render().contains("z_broken.dag")), "expected diagnostics to include z_broken.dag" ); assert!( result - .diagnostics + .diagnostics() .iter() .any(|diag| diag.render().contains("a_broken.dag")), "expected diagnostics to include a_broken.dag" ); assert!( - result.diagnostics[0].render().contains("a_broken.dag"), + result.diagnostics()[0].render().contains("a_broken.dag"), "diagnostics should be deterministically sorted by path" ); @@ -1530,17 +1377,17 @@ mod tests { let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); assert!( - result.diagnostics.len() >= 2, + result.diagnostics().len() >= 2, "expected lexical and parse diagnostics" ); assert_eq!( - result.diagnostics[0].kind, + result.diagnostics()[0].kind, DiagnosticKind::Lex, "lex diagnostics should sort before parse diagnostics" ); assert!( result - .diagnostics + .diagnostics() .iter() .any(|diag| diag.kind == DiagnosticKind::Parse), "expected at least one parse diagnostic" @@ -1578,10 +1425,10 @@ mod tests { ) .expect("second run should complete"); - assert_eq!(first.parsed_count, second.parsed_count); - assert_eq!(first.diagnostics, second.diagnostics); + assert_eq!(first.parsed_count(), second.parsed_count()); + assert_eq!(first.diagnostics(), second.diagnostics()); assert!( - first.diagnostics.len() >= 2, + first.diagnostics().len() >= 2, "expected diagnostics from both parse and lexical failures" ); @@ -1604,9 +1451,9 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); - assert_eq!(result.parsed_count, 1, "expected parsed module count"); + assert_eq!(result.parsed_count(), 1, "expected parsed module count"); assert!( - result.diagnostics.is_empty(), + result.diagnostics().is_empty(), "parse pipeline should not emit resolve diagnostics" ); @@ -1628,9 +1475,9 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); - assert_eq!(result.parsed_count, 1, "target file mode should parse one file"); + assert_eq!(result.parsed_count(), 1, "target file mode should parse one file"); assert!( - result.diagnostics.is_empty(), + result.diagnostics().is_empty(), "target file mode should ignore sibling invalid files" ); @@ -1672,8 +1519,8 @@ mod tests { target_file: Some(link), }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); - assert_eq!(result.parsed_count, 1); - assert!(result.diagnostics.is_empty()); + assert_eq!(result.parsed_count(), 1); + assert!(result.diagnostics().is_empty()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -1718,23 +1565,30 @@ mod tests { target_file: Some(link.clone()), }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); - assert_eq!(result.parsed_count, 0, "malformed target should not parse successfully"); + assert_eq!( + result.parsed_count(), + 0, + "malformed target should not parse successfully" + ); assert!( - !result.diagnostics.is_empty(), + !result.diagnostics().is_empty(), "malformed target should emit parse diagnostics" ); assert!( - result.diagnostics.iter().all(|diag| diag.file.as_ref() == Some(&real)), + result + .diagnostics() + .iter() + .all(|diag| diag.file.as_ref() == Some(&real)), "target-file parse diagnostics should reference canonical real path: {:?}", - result.diagnostics + result.diagnostics() ); assert!( result - .diagnostics + .diagnostics() .iter() .all(|diag| !diag.render().contains("link.dag")), "diagnostics should not reference symlink alias path: {:?}", - result.diagnostics + result.diagnostics() ); fs::remove_dir_all(root).expect("failed to cleanup temp root"); @@ -1760,12 +1614,12 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Report).expect("pipeline should execute"); - let report = result.report.expect("report output should be present"); + let report = result.report().expect("report output should be present"); assert!( report.contains("nested.no_module"), "target-file symlink fallback should derive nested module path via real root: {report}" ); - assert!(result.diagnostics.is_empty()); + assert!(result.diagnostics().is_empty()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -1802,8 +1656,8 @@ mod tests { ) .expect("second pipeline run should execute"); - assert_eq!(first.diagnostics, second.diagnostics); - assert_eq!(first.report, second.report); + assert_eq!(first.diagnostics(), second.diagnostics()); + assert_eq!(first.report(), second.report()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -1833,9 +1687,9 @@ mod tests { ) .expect("second pipeline run should execute"); - assert_eq!(first.diagnostics, second.diagnostics); - assert_eq!(first.report, second.report); - let report = first.report.expect("report should be present"); + assert_eq!(first.diagnostics(), second.diagnostics()); + assert_eq!(first.report(), second.report()); + let report = first.report().expect("report should be present"); assert!( report.contains("no_module"), "expected fallback module-path output: {report}" @@ -1859,8 +1713,8 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse) .expect("target-file mode should not require directory roots"); - assert_eq!(result.parsed_count, 1); - assert!(result.diagnostics.is_empty()); + assert_eq!(result.parsed_count(), 1); + assert!(result.diagnostics().is_empty()); fs::remove_dir_all(valid_root).expect("failed to cleanup temp root"); } @@ -1873,8 +1727,8 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse) .expect("empty roots should be valid for parse pipeline"); - assert_eq!(result.parsed_count, 0); - assert!(result.diagnostics.is_empty()); + assert_eq!(result.parsed_count(), 0); + assert!(result.diagnostics().is_empty()); } #[test] @@ -1894,10 +1748,10 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); assert_eq!( - result.parsed_count, 1, + result.parsed_count(), 1, "overlapping roots should not duplicate parsed files" ); - assert!(result.diagnostics.is_empty()); + assert!(result.diagnostics().is_empty()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -1915,10 +1769,10 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); assert_eq!( - result.parsed_count, 1, + result.parsed_count(), 1, "duplicate roots should not duplicate parsed files" ); - assert!(result.diagnostics.is_empty()); + assert!(result.diagnostics().is_empty()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -1936,10 +1790,10 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); assert_eq!( - result.parsed_count, 1, + result.parsed_count(), 1, "equivalent roots should not duplicate parsed files" ); - assert!(result.diagnostics.is_empty()); + assert!(result.diagnostics().is_empty()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -1957,7 +1811,7 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); let broken_hits = result - .diagnostics + .diagnostics() .iter() .filter(|diag| diag.render().contains("broken.dag")) .count(); @@ -1982,7 +1836,7 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); let broken_hits = result - .diagnostics + .diagnostics() .iter() .filter(|diag| diag.render().contains("broken.dag")) .count(); @@ -2018,10 +1872,10 @@ mod tests { ) .expect("second run should complete"); - assert_eq!(first.parsed_count, second.parsed_count); - assert_eq!(first.diagnostics, second.diagnostics); - assert_eq!(first.parsed_count, 1); - assert!(first.diagnostics.is_empty()); + assert_eq!(first.parsed_count(), second.parsed_count()); + assert_eq!(first.diagnostics(), second.diagnostics()); + assert_eq!(first.parsed_count(), 1); + assert!(first.diagnostics().is_empty()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -2050,10 +1904,10 @@ mod tests { ) .expect("second run should complete"); - assert_eq!(first.parsed_count, second.parsed_count); - assert_eq!(first.diagnostics, second.diagnostics); + assert_eq!(first.parsed_count(), second.parsed_count()); + assert_eq!(first.diagnostics(), second.diagnostics()); assert!( - !first.diagnostics.is_empty(), + !first.diagnostics().is_empty(), "expected parse diagnostics from malformed source" ); @@ -2079,10 +1933,10 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); assert_eq!( - result.parsed_count, 1, + result.parsed_count(), 1, "real+symlink roots should not duplicate parsed files" ); - assert!(result.diagnostics.is_empty()); + assert!(result.diagnostics().is_empty()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -2105,8 +1959,8 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse) .expect("pipeline should handle directory cycle symlink"); - assert_eq!(result.parsed_count, 1); - assert!(result.diagnostics.is_empty()); + assert_eq!(result.parsed_count(), 1); + assert!(result.diagnostics().is_empty()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -2141,8 +1995,8 @@ mod tests { ) .expect("second run should complete"); - assert_eq!(first.parsed_count, second.parsed_count); - assert_eq!(first.diagnostics, second.diagnostics); + assert_eq!(first.parsed_count(), second.parsed_count()); + assert_eq!(first.diagnostics(), second.diagnostics()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -2177,10 +2031,10 @@ mod tests { ) .expect("second run should complete"); - assert_eq!(first.parsed_count, second.parsed_count); - assert_eq!(first.diagnostics, second.diagnostics); + assert_eq!(first.parsed_count(), second.parsed_count()); + assert_eq!(first.diagnostics(), second.diagnostics()); assert!( - !first.diagnostics.is_empty(), + !first.diagnostics().is_empty(), "expected parse diagnostics for malformed source" ); @@ -2206,7 +2060,7 @@ mod tests { let result = run_pipeline(&context, PipelineStop::Parse) .expect("pipeline should handle directory cycle symlink"); let broken_hits = result - .diagnostics + .diagnostics() .iter() .filter(|diag| diag.render().contains("broken.dag")) .count(); @@ -2247,8 +2101,8 @@ mod tests { ) .expect("second pipeline run should complete"); - assert_eq!(first.parsed_count, second.parsed_count); - assert_eq!(first.diagnostics, second.diagnostics); + assert_eq!(first.parsed_count(), second.parsed_count()); + assert_eq!(first.diagnostics(), second.diagnostics()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -2272,7 +2126,7 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Parse).expect("pipeline should execute"); let broken_hits = result - .diagnostics + .diagnostics() .iter() .filter(|diag| diag.render().contains("broken.dag")) .count(); @@ -2327,8 +2181,8 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Report).expect("pipeline should execute"); - assert!(result.diagnostics.is_empty()); - let report = result.report.expect("report output should be present"); + assert!(result.diagnostics().is_empty()); + let report = result.report().expect("report output should be present"); assert!( report.contains("nested.no_module"), "module-path fallback should be derived relative to symlink root: {report}" @@ -2358,7 +2212,7 @@ mod tests { ) .expect("report pipeline should execute"); let broken_hits = result - .diagnostics + .diagnostics() .iter() .filter(|diag| diag.render().contains("broken.dag")) .count(); @@ -2399,8 +2253,8 @@ mod tests { ) .expect("second report pipeline run should execute"); - assert_eq!(first.diagnostics, second.diagnostics); - assert_eq!(first.report, second.report); + assert_eq!(first.diagnostics(), second.diagnostics()); + assert_eq!(first.report(), second.report()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -2413,10 +2267,10 @@ mod tests { }; let result = run_pipeline(&context, PipelineStop::Report) .expect("empty roots should be valid for report pipeline"); - let report = result.report.expect("report should be generated"); + let report = result.report().expect("report should be generated"); assert!(report.contains("Discovered modules:")); assert!(!report.contains("Diagnostics:")); - assert!(result.diagnostics.is_empty()); + assert!(result.diagnostics().is_empty()); } #[test] @@ -2514,7 +2368,7 @@ mod tests { let result = run_pipeline(&context, PipelineStop::Report).expect("pipeline should execute"); let unresolved = result - .diagnostics + .diagnostics() .iter() .find(|diag| diag.render().contains("unresolved import")) .expect("expected unresolved import diagnostic in report pipeline"); @@ -2546,7 +2400,7 @@ mod tests { let result = run_pipeline(&context, PipelineStop::Report).expect("pipeline should execute"); let duplicate = result - .diagnostics + .diagnostics() .iter() .find(|diag| diag.render().contains("duplicate module path")) .expect("expected duplicate module diagnostic in report pipeline"); @@ -2577,7 +2431,7 @@ mod tests { let result = run_pipeline(&context, PipelineStop::Report).expect("pipeline should execute"); let cycle = result - .diagnostics + .diagnostics() .iter() .find(|diag| diag.render().contains("cyclic dependencies detected")) .expect("expected cycle diagnostic in report pipeline"); @@ -2604,7 +2458,7 @@ mod tests { assert!( result - .diagnostics + .diagnostics() .iter() .any(|diag| diag.render().contains("unresolved import")), "expected unresolved import diagnostic in report pipeline" @@ -2630,7 +2484,7 @@ mod tests { let result = run_pipeline(&context, PipelineStop::Report).expect("pipeline should execute"); let unresolved_count = result - .diagnostics + .diagnostics() .iter() .filter(|diag| diag.render().contains("unresolved import")) .count(); @@ -2661,16 +2515,16 @@ mod tests { let result = run_pipeline(&context, PipelineStop::Report).expect("pipeline should execute"); assert!( - result.diagnostics.len() >= 2, + result.diagnostics().len() >= 2, "expected at least two diagnostics from lex + resolve phases" ); assert_eq!( - result.diagnostics[0].kind, + result.diagnostics()[0].kind, DiagnosticKind::Lex, "lex diagnostics should be sorted before resolve diagnostics" ); assert!( - result.diagnostics + result.diagnostics() .iter() .any(|diag| diag.kind == DiagnosticKind::Resolve), "expected at least one resolve diagnostic" @@ -2699,8 +2553,8 @@ mod tests { let second = run_pipeline(&context, PipelineStop::Report).expect("second run should succeed"); - assert_eq!(first.diagnostics, second.diagnostics); - assert_eq!(first.report, second.report); + assert_eq!(first.diagnostics(), second.diagnostics()); + assert_eq!(first.report(), second.report()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -2734,8 +2588,8 @@ mod tests { ) .expect("second run should succeed"); - assert_eq!(first.diagnostics, second.diagnostics); - assert_eq!(first.report, second.report); + assert_eq!(first.diagnostics(), second.diagnostics()); + assert_eq!(first.report(), second.report()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -2764,8 +2618,8 @@ mod tests { ) .expect("second run should succeed"); - assert_eq!(first.diagnostics, second.diagnostics); - assert_eq!(first.report, second.report); + assert_eq!(first.diagnostics(), second.diagnostics()); + assert_eq!(first.report(), second.report()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -2794,10 +2648,10 @@ mod tests { ) .expect("second run should complete"); - assert_eq!(first.diagnostics, second.diagnostics); - assert_eq!(first.report, second.report); + assert_eq!(first.diagnostics(), second.diagnostics()); + assert_eq!(first.report(), second.report()); assert!( - !first.diagnostics.is_empty(), + !first.diagnostics().is_empty(), "expected diagnostics from malformed source" ); @@ -2834,8 +2688,8 @@ mod tests { ) .expect("second run should succeed"); - assert_eq!(first.diagnostics, second.diagnostics); - assert_eq!(first.report, second.report); + assert_eq!(first.diagnostics(), second.diagnostics()); + assert_eq!(first.report(), second.report()); fs::remove_dir_all(root).expect("failed to cleanup temp root"); } @@ -2870,10 +2724,10 @@ mod tests { ) .expect("second run should complete"); - assert_eq!(first.diagnostics, second.diagnostics); - assert_eq!(first.report, second.report); + assert_eq!(first.diagnostics(), second.diagnostics()); + assert_eq!(first.report(), second.report()); assert!( - !first.diagnostics.is_empty(), + !first.diagnostics().is_empty(), "expected report diagnostics for malformed source" ); diff --git a/core/daglang/daglang-cli/tests/cli_commands.rs b/core/daglang/daglang-cli/tests/cli_commands.rs index 0d78c5c7a6d..0925d6a54fe 100644 --- a/core/daglang/daglang-cli/tests/cli_commands.rs +++ b/core/daglang/daglang-cli/tests/cli_commands.rs @@ -4,7 +4,7 @@ use daglang_resolve::ModuleGraph; use std::collections::BTreeMap; use std::path::PathBuf; -use std::process::Command; +use std::process::{Command, Output}; use std::time::{SystemTime, UNIX_EPOCH}; fn workspace_root() -> PathBuf { @@ -23,14 +23,6 @@ fn unique_temp_file(name: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("daglang_cli_{name}_{}_{}.dag", std::process::id(), nanos)) } -fn unique_temp_output_file(name: &str) -> std::path::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_{name}_{}_{}.mk", std::process::id(), nanos)) -} - fn unique_temp_dir(name: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -251,38 +243,6 @@ fn expected_viz_self_mermaid() -> &'static str { ) } -fn expected_viz_self_ascii() -> &'static str { - concat!( - "ASCII DAG: daglang-compiler-pipeline\n", - "Nodes:\n", - " - build_module_graph\n", - " inputs: diagnostics:Vec(1), parsed_modules:Vec(1)\n", - " outputs: diagnostics:Vec(1), module_graph:ModuleGraph(1)\n", - " - discover_files\n", - " inputs: context:PipelineContext(1)\n", - " outputs: diagnostics:Vec(1), files:Vec(1)\n", - " - parse_all\n", - " inputs: diagnostics:Vec(1), files:Vec(1)\n", - " outputs: diagnostics:Vec(1), parsed_modules:Vec(1)\n", - " - report_modules\n", - " inputs: diagnostics:Vec(1), module_graph:ModuleGraph(1)\n", - " outputs: diagnostics:Vec(1), report:String(1)\n", - "Edges:\n", - " - build_module_graph.diagnostics -> report_modules.diagnostics\n", - " - build_module_graph.module_graph -> report_modules.module_graph\n", - " - discover_files.diagnostics -> parse_all.diagnostics\n", - " - discover_files.files -> parse_all.files\n", - " - parse_all.diagnostics -> build_module_graph.diagnostics\n", - " - parse_all.parsed_modules -> build_module_graph.parsed_modules\n", - "Waves:\n", - " [0] discover_files\n", - " [1] parse_all\n", - " [2] build_module_graph\n", - " [3] report_modules\n", - "\n", - ) -} - fn resolve_discovered_module_order() -> Vec { ModuleGraph::discover(&[workspace_root().join("dsl")]) .expect("resolve discovery should succeed for real corpus") @@ -757,6 +717,310 @@ fn check_command_mixed_case_dag_extension_directory_with_errors_matches_trailing std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[test] +fn check_command_curdir_suffix_dag_extension_directory_matches_plain_output() { + let root = unique_temp_dir("check_curdir_suffix_dag_extension_directory"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .dag directory root"); + std::fs::write(dag_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in .dag directory"); + + let plain = Command::new(daglang_bin()) + .arg("check") + .arg("bundle.dag") + .current_dir(&root) + .output() + .expect("failed to run check on plain .dag directory"); + assert!( + plain.status.success(), + "plain .dag directory check should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("check") + .arg("./bundle.dag") + .current_dir(&root) + .output() + .expect("failed to run check on curdir-suffix .dag directory"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix .dag directory check should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix .dag directory check stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix .dag directory check stderr should match" + ); + assert_eq!( + String::from_utf8_lossy(&plain.stdout), + expected_check_success_stdout(1), + "curdir-suffix .dag directory check should parse exactly one file" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn check_command_curdir_suffix_dag_extension_directory_with_errors_matches_plain_output() { + let root = unique_temp_dir("check_curdir_suffix_dag_extension_directory_errors"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .dag directory root"); + let broken_file = dag_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in .dag directory"); + + let plain = Command::new(daglang_bin()) + .arg("check") + .arg("bundle.dag") + .current_dir(&root) + .output() + .expect("failed to run check on malformed plain .dag directory"); + assert!( + !plain.status.success(), + "malformed plain .dag directory check should fail" + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("check") + .arg("./bundle.dag") + .current_dir(&root) + .output() + .expect("failed to run check on malformed curdir-suffix .dag directory"); + assert!( + !curdir_suffix.status.success(), + "malformed curdir-suffix .dag directory check should fail" + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "malformed plain and curdir-suffix .dag directory check stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "malformed plain and curdir-suffix .dag directory check stderr should match" + ); + let canonical_broken_file = broken_file + .canonicalize() + .expect("broken source should canonicalize"); + assert!( + String::from_utf8_lossy(&plain.stderr) + .contains(&format!("{}:2:3:", canonical_broken_file.display())), + "malformed .dag directory diagnostics should include canonical broken-file path: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn check_command_curdir_suffix_uppercase_dag_extension_directory_matches_plain_output() { + let root = unique_temp_dir("check_curdir_suffix_uppercase_dag_extension_directory"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory root"); + std::fs::write(dag_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in .DAG directory"); + + let plain = Command::new(daglang_bin()) + .arg("check") + .arg("bundle.DAG") + .current_dir(&root) + .output() + .expect("failed to run check on plain .DAG directory"); + assert!( + plain.status.success(), + "plain .DAG directory check should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("check") + .arg("./bundle.DAG") + .current_dir(&root) + .output() + .expect("failed to run check on curdir-suffix .DAG directory"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix .DAG directory check should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix .DAG directory check stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix .DAG directory check stderr should match" + ); + assert_eq!( + String::from_utf8_lossy(&plain.stdout), + expected_check_success_stdout(1), + "curdir-suffix .DAG directory check should parse exactly one file" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn check_command_curdir_suffix_uppercase_dag_extension_directory_with_errors_matches_plain_output() { + let root = unique_temp_dir("check_curdir_suffix_uppercase_dag_extension_directory_errors"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory root"); + let broken_file = dag_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in .DAG directory"); + + let plain = Command::new(daglang_bin()) + .arg("check") + .arg("bundle.DAG") + .current_dir(&root) + .output() + .expect("failed to run check on malformed plain .DAG directory"); + assert!( + !plain.status.success(), + "malformed plain .DAG directory check should fail" + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("check") + .arg("./bundle.DAG") + .current_dir(&root) + .output() + .expect("failed to run check on malformed curdir-suffix .DAG directory"); + assert!( + !curdir_suffix.status.success(), + "malformed curdir-suffix .DAG directory check should fail" + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "malformed plain and curdir-suffix .DAG directory check stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "malformed plain and curdir-suffix .DAG directory check stderr should match" + ); + let canonical_broken_file = broken_file + .canonicalize() + .expect("broken source should canonicalize"); + assert!( + String::from_utf8_lossy(&plain.stderr) + .contains(&format!("{}:2:3:", canonical_broken_file.display())), + "malformed .DAG directory diagnostics should include canonical broken-file path: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn check_command_curdir_suffix_mixed_case_dag_extension_directory_matches_plain_output() { + let root = unique_temp_dir("check_curdir_suffix_mixed_case_dag_extension_directory"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory root"); + std::fs::write(dag_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in .DaG directory"); + + let plain = Command::new(daglang_bin()) + .arg("check") + .arg("bundle.DaG") + .current_dir(&root) + .output() + .expect("failed to run check on plain .DaG directory"); + assert!( + plain.status.success(), + "plain .DaG directory check should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("check") + .arg("./bundle.DaG") + .current_dir(&root) + .output() + .expect("failed to run check on curdir-suffix .DaG directory"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix .DaG directory check should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix .DaG directory check stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix .DaG directory check stderr should match" + ); + assert_eq!( + String::from_utf8_lossy(&plain.stdout), + expected_check_success_stdout(1), + "curdir-suffix .DaG directory check should parse exactly one file" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn check_command_curdir_suffix_mixed_case_dag_extension_directory_with_errors_matches_plain_output() +{ + let root = unique_temp_dir("check_curdir_suffix_mixed_case_dag_extension_directory_errors"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory root"); + let broken_file = dag_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in .DaG directory"); + + let plain = Command::new(daglang_bin()) + .arg("check") + .arg("bundle.DaG") + .current_dir(&root) + .output() + .expect("failed to run check on malformed plain .DaG directory"); + assert!( + !plain.status.success(), + "malformed plain .DaG directory check should fail" + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("check") + .arg("./bundle.DaG") + .current_dir(&root) + .output() + .expect("failed to run check on malformed curdir-suffix .DaG directory"); + assert!( + !curdir_suffix.status.success(), + "malformed curdir-suffix .DaG directory check should fail" + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "malformed plain and curdir-suffix .DaG directory check stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "malformed plain and curdir-suffix .DaG directory check stderr should match" + ); + let canonical_broken_file = broken_file + .canonicalize() + .expect("broken source should canonicalize"); + assert!( + String::from_utf8_lossy(&plain.stderr) + .contains(&format!("{}:2:3:", canonical_broken_file.display())), + "malformed .DaG directory diagnostics should include canonical broken-file path: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + #[cfg(unix)] #[test] fn check_command_symlink_directory_named_dag_extension_matches_real_directory_output() { @@ -1092,216 +1356,561 @@ fn check_command_symlink_directory_named_mixed_case_dag_extension_with_errors_ma std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[cfg(unix)] #[test] -fn check_command_absolute_mixed_segment_root_matches_canonical_absolute_output() { - let cwd = workspace_root().join("core"); - let absolute_mixed = workspace_root().join(".").join("dsl"); - let absolute_canonical = workspace_root().join("dsl"); +fn check_command_curdir_suffix_symlink_directory_named_dag_extension_matches_plain_symlink_output() { + use std::os::unix::fs::symlink; - let mixed = Command::new(daglang_bin()) - .arg("check") - .arg(&absolute_mixed) - .current_dir(&cwd) - .output() - .expect("failed to run mixed-segment absolute-root daglang check"); - assert!( - mixed.status.success(), - "mixed-segment absolute-root check should succeed: {}", - String::from_utf8_lossy(&mixed.stderr) + let root = unique_temp_dir("check_curdir_suffix_symlink_directory_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + let plain = Command::new(daglang_bin()) + .arg("check") + .arg("link.dag") + .current_dir(&root) + .output() + .expect("failed to run check on plain .dag directory symlink"); + assert!( + plain.status.success(), + "plain .dag directory symlink check should succeed: {}", + String::from_utf8_lossy(&plain.stderr) ); - let canonical = Command::new(daglang_bin()) + let curdir_suffix = Command::new(daglang_bin()) .arg("check") - .arg(&absolute_canonical) - .current_dir(&cwd) + .arg("./link.dag") + .current_dir(&root) .output() - .expect("failed to run canonical absolute-root daglang check"); + .expect("failed to run check on curdir-suffix .dag directory symlink"); assert!( - canonical.status.success(), - "canonical absolute-root check should succeed: {}", - String::from_utf8_lossy(&canonical.stderr) + curdir_suffix.status.success(), + "curdir-suffix .dag directory symlink check should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) ); assert_eq!( - mixed.stdout, canonical.stdout, - "mixed-segment and canonical absolute-root check stdout should match" + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix .dag directory symlink check stdout should match" ); assert_eq!( - mixed.stderr, canonical.stderr, - "mixed-segment and canonical absolute-root check stderr should match" + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix .dag directory symlink check stderr should match" ); + assert_eq!( + String::from_utf8_lossy(&plain.stdout), + expected_check_success_stdout(1), + "curdir-suffix .dag directory symlink check should parse exactly one file" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[cfg(unix)] #[test] -fn check_command_absolute_curdir_segment_trailing_slash_root_matches_canonical_absolute_output() { - let cwd = workspace_root().join("core"); - let absolute_curdir_segment_trailing = workspace_root().join("dsl/./"); - let absolute_canonical = workspace_root().join("dsl"); +fn check_command_curdir_suffix_symlink_directory_named_dag_extension_with_errors_matches_plain_symlink_output( +) { + use std::os::unix::fs::symlink; - let curdir_segment_trailing = Command::new(daglang_bin()) + let root = + unique_temp_dir("check_curdir_suffix_symlink_directory_named_dag_extension_errors"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + let broken_file = real_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + let plain = Command::new(daglang_bin()) .arg("check") - .arg(&absolute_curdir_segment_trailing) - .current_dir(&cwd) + .arg("link.dag") + .current_dir(&root) .output() - .expect("failed to run curdir-segment-trailing absolute-root daglang check"); + .expect("failed to run check on malformed plain .dag directory symlink"); assert!( - curdir_segment_trailing.status.success(), - "curdir-segment-trailing absolute-root check should succeed: {}", - String::from_utf8_lossy(&curdir_segment_trailing.stderr) + !plain.status.success(), + "malformed plain .dag directory symlink check should fail" ); - let canonical = Command::new(daglang_bin()) + let curdir_suffix = Command::new(daglang_bin()) .arg("check") - .arg(&absolute_canonical) - .current_dir(&cwd) + .arg("./link.dag") + .current_dir(&root) .output() - .expect("failed to run canonical absolute-root daglang check"); + .expect("failed to run check on malformed curdir-suffix .dag directory symlink"); assert!( - canonical.status.success(), - "canonical absolute-root check should succeed: {}", - String::from_utf8_lossy(&canonical.stderr) + !curdir_suffix.status.success(), + "malformed curdir-suffix .dag directory symlink check should fail" ); assert_eq!( - curdir_segment_trailing.stdout, canonical.stdout, - "curdir-segment-trailing and canonical absolute-root check stdout should match" + plain.stdout, curdir_suffix.stdout, + "malformed plain and curdir-suffix .dag directory symlink check stdout should match" ); assert_eq!( - curdir_segment_trailing.stderr, canonical.stderr, - "curdir-segment-trailing and canonical absolute-root check stderr should match" + plain.stderr, curdir_suffix.stderr, + "malformed plain and curdir-suffix .dag directory symlink check stderr should match" + ); + let canonical_broken_file = broken_file + .canonicalize() + .expect("broken source should canonicalize"); + assert!( + String::from_utf8_lossy(&plain.stderr) + .contains(&format!("{}:2:3:", canonical_broken_file.display())), + "malformed .dag directory symlink diagnostics should include canonical broken-file path: {}", + String::from_utf8_lossy(&plain.stderr) ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[cfg(unix)] #[test] -fn check_command_absolute_curdir_suffix_root_matches_canonical_absolute_output() { - let cwd = workspace_root().join("core"); - let absolute_curdir_suffix = workspace_root().join("dsl/."); - let absolute_canonical = workspace_root().join("dsl"); +fn check_command_curdir_suffix_symlink_directory_named_uppercase_dag_extension_matches_plain_symlink_output( +) { + use std::os::unix::fs::symlink; - let curdir_suffix = Command::new(daglang_bin()) + let root = + unique_temp_dir("check_curdir_suffix_symlink_directory_named_uppercase_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DAG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DAG directory symlink"); + + let plain = Command::new(daglang_bin()) .arg("check") - .arg(&absolute_curdir_suffix) - .current_dir(&cwd) + .arg("link.DAG") + .current_dir(&root) .output() - .expect("failed to run curdir-suffix absolute-root daglang check"); + .expect("failed to run check on plain .DAG directory symlink"); assert!( - curdir_suffix.status.success(), - "curdir-suffix absolute-root check should succeed: {}", - String::from_utf8_lossy(&curdir_suffix.stderr) + plain.status.success(), + "plain .DAG directory symlink check should succeed: {}", + String::from_utf8_lossy(&plain.stderr) ); - let canonical = Command::new(daglang_bin()) + let curdir_suffix = Command::new(daglang_bin()) .arg("check") - .arg(&absolute_canonical) - .current_dir(&cwd) + .arg("./link.DAG") + .current_dir(&root) .output() - .expect("failed to run canonical absolute-root daglang check"); + .expect("failed to run check on curdir-suffix .DAG directory symlink"); assert!( - canonical.status.success(), - "canonical absolute-root check should succeed: {}", - String::from_utf8_lossy(&canonical.stderr) + curdir_suffix.status.success(), + "curdir-suffix .DAG directory symlink check should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) ); assert_eq!( - curdir_suffix.stdout, canonical.stdout, - "curdir-suffix and canonical absolute-root check stdout should match" + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix .DAG directory symlink check stdout should match" ); assert_eq!( - curdir_suffix.stderr, canonical.stderr, - "curdir-suffix and canonical absolute-root check stderr should match" + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix .DAG directory symlink check stderr should match" + ); + assert_eq!( + String::from_utf8_lossy(&plain.stdout), + expected_check_success_stdout(1), + "curdir-suffix .DAG directory symlink check should parse exactly one file" ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[cfg(unix)] #[test] -fn check_command_absolute_curdir_suffix_double_separator_root_matches_canonical_absolute_output() { - let cwd = workspace_root().join("core"); - let absolute_curdir_suffix_double_separator = - PathBuf::from(format!("{}//.", workspace_root().join("dsl").display())); - let absolute_canonical = workspace_root().join("dsl"); +fn check_command_curdir_suffix_symlink_directory_named_uppercase_dag_extension_with_errors_matches_plain_symlink_output( +) { + use std::os::unix::fs::symlink; - let curdir_suffix_double_separator = Command::new(daglang_bin()) + let root = unique_temp_dir( + "check_curdir_suffix_symlink_directory_named_uppercase_dag_extension_errors", + ); + let real_dir = root.join("real"); + let link_dir = root.join("link.DAG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + let broken_file = real_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DAG directory symlink"); + + let plain = Command::new(daglang_bin()) .arg("check") - .arg(&absolute_curdir_suffix_double_separator) - .current_dir(&cwd) + .arg("link.DAG") + .current_dir(&root) .output() - .expect("failed to run curdir-suffix-double-separator absolute-root daglang check"); + .expect("failed to run check on malformed plain .DAG directory symlink"); assert!( - curdir_suffix_double_separator.status.success(), - "curdir-suffix-double-separator absolute-root check should succeed: {}", - String::from_utf8_lossy(&curdir_suffix_double_separator.stderr) + !plain.status.success(), + "malformed plain .DAG directory symlink check should fail" ); - let canonical = Command::new(daglang_bin()) + let curdir_suffix = Command::new(daglang_bin()) .arg("check") - .arg(&absolute_canonical) - .current_dir(&cwd) + .arg("./link.DAG") + .current_dir(&root) .output() - .expect("failed to run canonical absolute-root daglang check"); + .expect("failed to run check on malformed curdir-suffix .DAG directory symlink"); assert!( - canonical.status.success(), - "canonical absolute-root check should succeed: {}", - String::from_utf8_lossy(&canonical.stderr) + !curdir_suffix.status.success(), + "malformed curdir-suffix .DAG directory symlink check should fail" ); assert_eq!( - curdir_suffix_double_separator.stdout, canonical.stdout, - "curdir-suffix-double-separator and canonical absolute-root check stdout should match" + plain.stdout, curdir_suffix.stdout, + "malformed plain and curdir-suffix .DAG directory symlink check stdout should match" ); assert_eq!( - curdir_suffix_double_separator.stderr, canonical.stderr, - "curdir-suffix-double-separator and canonical absolute-root check stderr should match" + plain.stderr, curdir_suffix.stderr, + "malformed plain and curdir-suffix .DAG directory symlink check stderr should match" + ); + let canonical_broken_file = broken_file + .canonicalize() + .expect("broken source should canonicalize"); + assert!( + String::from_utf8_lossy(&plain.stderr) + .contains(&format!("{}:2:3:", canonical_broken_file.display())), + "malformed .DAG directory symlink diagnostics should include canonical broken-file path: {}", + String::from_utf8_lossy(&plain.stderr) ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[cfg(unix)] #[test] -fn check_command_absolute_curdir_segment_suffix_root_matches_canonical_absolute_output() { - let cwd = workspace_root().join("core"); - let absolute_curdir_segment_suffix = workspace_root().join("dsl/./."); - let absolute_canonical = workspace_root().join("dsl"); +fn check_command_curdir_suffix_symlink_directory_named_mixed_case_dag_extension_matches_plain_symlink_output( +) { + use std::os::unix::fs::symlink; - let curdir_segment_suffix = Command::new(daglang_bin()) + let root = + unique_temp_dir("check_curdir_suffix_symlink_directory_named_mixed_case_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DaG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DaG directory symlink"); + + let plain = Command::new(daglang_bin()) .arg("check") - .arg(&absolute_curdir_segment_suffix) - .current_dir(&cwd) + .arg("link.DaG") + .current_dir(&root) .output() - .expect("failed to run curdir-segment-suffix absolute-root daglang check"); + .expect("failed to run check on plain .DaG directory symlink"); assert!( - curdir_segment_suffix.status.success(), - "curdir-segment-suffix absolute-root check should succeed: {}", - String::from_utf8_lossy(&curdir_segment_suffix.stderr) + plain.status.success(), + "plain .DaG directory symlink check should succeed: {}", + String::from_utf8_lossy(&plain.stderr) ); - let canonical = Command::new(daglang_bin()) + let curdir_suffix = Command::new(daglang_bin()) .arg("check") - .arg(&absolute_canonical) - .current_dir(&cwd) + .arg("./link.DaG") + .current_dir(&root) .output() - .expect("failed to run canonical absolute-root daglang check"); + .expect("failed to run check on curdir-suffix .DaG directory symlink"); assert!( - canonical.status.success(), - "canonical absolute-root check should succeed: {}", - String::from_utf8_lossy(&canonical.stderr) + curdir_suffix.status.success(), + "curdir-suffix .DaG directory symlink check should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) ); assert_eq!( - curdir_segment_suffix.stdout, canonical.stdout, - "curdir-segment-suffix and canonical absolute-root check stdout should match" + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix .DaG directory symlink check stdout should match" ); assert_eq!( - curdir_segment_suffix.stderr, canonical.stderr, - "curdir-segment-suffix and canonical absolute-root check stderr should match" + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix .DaG directory symlink check stderr should match" ); + assert_eq!( + String::from_utf8_lossy(&plain.stdout), + expected_check_success_stdout(1), + "curdir-suffix .DaG directory symlink check should parse exactly one file" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[cfg(unix)] #[test] -fn check_command_absolute_curdir_segment_double_separator_suffix_root_matches_canonical_absolute_output( +fn check_command_curdir_suffix_symlink_directory_named_mixed_case_dag_extension_with_errors_matches_plain_symlink_output( ) { - let cwd = workspace_root().join("core"); - let absolute_curdir_segment_double_separator_suffix = - PathBuf::from(format!("{}//./.", workspace_root().join("dsl").display())); - let absolute_canonical = workspace_root().join("dsl"); + use std::os::unix::fs::symlink; - let curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + let root = unique_temp_dir( + "check_curdir_suffix_symlink_directory_named_mixed_case_dag_extension_errors", + ); + let real_dir = root.join("real"); + let link_dir = root.join("link.DaG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + let broken_file = real_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DaG directory symlink"); + + let plain = Command::new(daglang_bin()) + .arg("check") + .arg("link.DaG") + .current_dir(&root) + .output() + .expect("failed to run check on malformed plain .DaG directory symlink"); + assert!( + !plain.status.success(), + "malformed plain .DaG directory symlink check should fail" + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("check") + .arg("./link.DaG") + .current_dir(&root) + .output() + .expect("failed to run check on malformed curdir-suffix .DaG directory symlink"); + assert!( + !curdir_suffix.status.success(), + "malformed curdir-suffix .DaG directory symlink check should fail" + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "malformed plain and curdir-suffix .DaG directory symlink check stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "malformed plain and curdir-suffix .DaG directory symlink check stderr should match" + ); + let canonical_broken_file = broken_file + .canonicalize() + .expect("broken source should canonicalize"); + assert!( + String::from_utf8_lossy(&plain.stderr) + .contains(&format!("{}:2:3:", canonical_broken_file.display())), + "malformed .DaG directory symlink diagnostics should include canonical broken-file path: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn check_command_absolute_mixed_segment_root_matches_canonical_absolute_output() { + let cwd = workspace_root().join("core"); + let absolute_mixed = workspace_root().join(".").join("dsl"); + let absolute_canonical = workspace_root().join("dsl"); + + let mixed = Command::new(daglang_bin()) + .arg("check") + .arg(&absolute_mixed) + .current_dir(&cwd) + .output() + .expect("failed to run mixed-segment absolute-root daglang check"); + assert!( + mixed.status.success(), + "mixed-segment absolute-root check should succeed: {}", + String::from_utf8_lossy(&mixed.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("check") + .arg(&absolute_canonical) + .current_dir(&cwd) + .output() + .expect("failed to run canonical absolute-root daglang check"); + assert!( + canonical.status.success(), + "canonical absolute-root check should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + mixed.stdout, canonical.stdout, + "mixed-segment and canonical absolute-root check stdout should match" + ); + assert_eq!( + mixed.stderr, canonical.stderr, + "mixed-segment and canonical absolute-root check stderr should match" + ); +} + +#[test] +fn check_command_absolute_curdir_segment_trailing_slash_root_matches_canonical_absolute_output() { + let cwd = workspace_root().join("core"); + let absolute_curdir_segment_trailing = workspace_root().join("dsl/./"); + let absolute_canonical = workspace_root().join("dsl"); + + let curdir_segment_trailing = Command::new(daglang_bin()) + .arg("check") + .arg(&absolute_curdir_segment_trailing) + .current_dir(&cwd) + .output() + .expect("failed to run curdir-segment-trailing absolute-root daglang check"); + assert!( + curdir_segment_trailing.status.success(), + "curdir-segment-trailing absolute-root check should succeed: {}", + String::from_utf8_lossy(&curdir_segment_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("check") + .arg(&absolute_canonical) + .current_dir(&cwd) + .output() + .expect("failed to run canonical absolute-root daglang check"); + assert!( + canonical.status.success(), + "canonical absolute-root check should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_segment_trailing.stdout, canonical.stdout, + "curdir-segment-trailing and canonical absolute-root check stdout should match" + ); + assert_eq!( + curdir_segment_trailing.stderr, canonical.stderr, + "curdir-segment-trailing and canonical absolute-root check stderr should match" + ); +} + +#[test] +fn check_command_absolute_curdir_suffix_root_matches_canonical_absolute_output() { + let cwd = workspace_root().join("core"); + let absolute_curdir_suffix = workspace_root().join("dsl/."); + let absolute_canonical = workspace_root().join("dsl"); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("check") + .arg(&absolute_curdir_suffix) + .current_dir(&cwd) + .output() + .expect("failed to run curdir-suffix absolute-root daglang check"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix absolute-root check should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("check") + .arg(&absolute_canonical) + .current_dir(&cwd) + .output() + .expect("failed to run canonical absolute-root daglang check"); + assert!( + canonical.status.success(), + "canonical absolute-root check should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_suffix.stdout, canonical.stdout, + "curdir-suffix and canonical absolute-root check stdout should match" + ); + assert_eq!( + curdir_suffix.stderr, canonical.stderr, + "curdir-suffix and canonical absolute-root check stderr should match" + ); +} + +#[test] +fn check_command_absolute_curdir_suffix_double_separator_root_matches_canonical_absolute_output() { + let cwd = workspace_root().join("core"); + let absolute_curdir_suffix_double_separator = + PathBuf::from(format!("{}//.", workspace_root().join("dsl").display())); + let absolute_canonical = workspace_root().join("dsl"); + + let curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("check") + .arg(&absolute_curdir_suffix_double_separator) + .current_dir(&cwd) + .output() + .expect("failed to run curdir-suffix-double-separator absolute-root daglang check"); + assert!( + curdir_suffix_double_separator.status.success(), + "curdir-suffix-double-separator absolute-root check should succeed: {}", + String::from_utf8_lossy(&curdir_suffix_double_separator.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("check") + .arg(&absolute_canonical) + .current_dir(&cwd) + .output() + .expect("failed to run canonical absolute-root daglang check"); + assert!( + canonical.status.success(), + "canonical absolute-root check 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 check stdout should match" + ); + assert_eq!( + curdir_suffix_double_separator.stderr, canonical.stderr, + "curdir-suffix-double-separator and canonical absolute-root check stderr should match" + ); +} + +#[test] +fn check_command_absolute_curdir_segment_suffix_root_matches_canonical_absolute_output() { + let cwd = workspace_root().join("core"); + let absolute_curdir_segment_suffix = workspace_root().join("dsl/./."); + let absolute_canonical = workspace_root().join("dsl"); + + let curdir_segment_suffix = Command::new(daglang_bin()) + .arg("check") + .arg(&absolute_curdir_segment_suffix) + .current_dir(&cwd) + .output() + .expect("failed to run curdir-segment-suffix absolute-root daglang check"); + assert!( + curdir_segment_suffix.status.success(), + "curdir-segment-suffix absolute-root check should succeed: {}", + String::from_utf8_lossy(&curdir_segment_suffix.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("check") + .arg(&absolute_canonical) + .current_dir(&cwd) + .output() + .expect("failed to run canonical absolute-root daglang check"); + assert!( + canonical.status.success(), + "canonical absolute-root check should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_segment_suffix.stdout, canonical.stdout, + "curdir-segment-suffix and canonical absolute-root check stdout should match" + ); + assert_eq!( + curdir_segment_suffix.stderr, canonical.stderr, + "curdir-segment-suffix and canonical absolute-root check stderr should match" + ); +} + +#[test] +fn check_command_absolute_curdir_segment_double_separator_suffix_root_matches_canonical_absolute_output( +) { + let cwd = workspace_root().join("core"); + let absolute_curdir_segment_double_separator_suffix = + PathBuf::from(format!("{}//./.", workspace_root().join("dsl").display())); + let absolute_canonical = workspace_root().join("dsl"); + + let curdir_segment_double_separator_suffix = Command::new(daglang_bin()) .arg("check") .arg(&absolute_curdir_segment_double_separator_suffix) .current_dir(&cwd) @@ -16680,17 +17289,313 @@ fn modules_command_mixed_case_dag_extension_directory_with_errors_matches_traili std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } -#[cfg(unix)] #[test] -fn modules_command_symlink_directory_named_dag_extension_matches_real_directory_output() { - use std::os::unix::fs::symlink; +fn modules_command_curdir_suffix_dag_extension_directory_matches_plain_output() { + let root = unique_temp_dir("modules_curdir_suffix_dag_extension_directory"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .dag directory root"); + std::fs::write(dag_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in .dag directory"); - let root = unique_temp_dir("modules_symlink_directory_named_dag_extension"); - let real_dir = root.join("real"); - let link_dir = root.join("link.dag"); - std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); - std::fs::write(real_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") - .expect("failed to write valid source in real directory"); + let plain = Command::new(daglang_bin()) + .arg("modules") + .arg("bundle.dag") + .current_dir(&root) + .output() + .expect("failed to run modules on plain .dag directory"); + assert!( + plain.status.success(), + "plain .dag directory modules should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("modules") + .arg("./bundle.dag") + .current_dir(&root) + .output() + .expect("failed to run modules on curdir-suffix .dag directory"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix .dag directory modules should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix .dag directory modules stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix .dag directory modules stderr should match" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn modules_command_curdir_suffix_dag_extension_directory_with_errors_matches_plain_output() { + let root = unique_temp_dir("modules_curdir_suffix_dag_extension_directory_errors"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .dag directory root"); + let broken_file = dag_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in .dag directory"); + + let plain = Command::new(daglang_bin()) + .arg("modules") + .arg("bundle.dag") + .current_dir(&root) + .output() + .expect("failed to run modules on malformed plain .dag directory"); + assert!( + plain.status.success(), + "malformed plain .dag directory modules should succeed while reporting diagnostics: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("modules") + .arg("./bundle.dag") + .current_dir(&root) + .output() + .expect("failed to run modules on malformed curdir-suffix .dag directory"); + assert!( + curdir_suffix.status.success(), + "malformed curdir-suffix .dag directory modules should succeed while reporting diagnostics: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "malformed plain and curdir-suffix .dag directory modules stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "malformed plain and curdir-suffix .dag directory modules stderr should match" + ); + let canonical_broken_file = broken_file + .canonicalize() + .expect("broken source should canonicalize"); + assert!( + String::from_utf8_lossy(&plain.stdout) + .contains(&format!("{}:2:3:", canonical_broken_file.display())), + "malformed .dag directory diagnostics should include canonical broken-file path: {}", + String::from_utf8_lossy(&plain.stdout) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn modules_command_curdir_suffix_uppercase_dag_extension_directory_matches_plain_output() { + let root = unique_temp_dir("modules_curdir_suffix_uppercase_dag_extension_directory"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory root"); + std::fs::write(dag_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in .DAG directory"); + + let plain = Command::new(daglang_bin()) + .arg("modules") + .arg("bundle.DAG") + .current_dir(&root) + .output() + .expect("failed to run modules on plain .DAG directory"); + assert!( + plain.status.success(), + "plain .DAG directory modules should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("modules") + .arg("./bundle.DAG") + .current_dir(&root) + .output() + .expect("failed to run modules on curdir-suffix .DAG directory"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix .DAG directory modules should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix .DAG directory modules stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix .DAG directory modules stderr should match" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn modules_command_curdir_suffix_uppercase_dag_extension_directory_with_errors_matches_plain_output( +) { + let root = unique_temp_dir("modules_curdir_suffix_uppercase_dag_extension_directory_errors"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory root"); + let broken_file = dag_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in .DAG directory"); + + let plain = Command::new(daglang_bin()) + .arg("modules") + .arg("bundle.DAG") + .current_dir(&root) + .output() + .expect("failed to run modules on malformed plain .DAG directory"); + assert!( + plain.status.success(), + "malformed plain .DAG directory modules should succeed while reporting diagnostics: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("modules") + .arg("./bundle.DAG") + .current_dir(&root) + .output() + .expect("failed to run modules on malformed curdir-suffix .DAG directory"); + assert!( + curdir_suffix.status.success(), + "malformed curdir-suffix .DAG directory modules should succeed while reporting diagnostics: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "malformed plain and curdir-suffix .DAG directory modules stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "malformed plain and curdir-suffix .DAG directory modules stderr should match" + ); + let canonical_broken_file = broken_file + .canonicalize() + .expect("broken source should canonicalize"); + assert!( + String::from_utf8_lossy(&plain.stdout) + .contains(&format!("{}:2:3:", canonical_broken_file.display())), + "malformed .DAG directory diagnostics should include canonical broken-file path: {}", + String::from_utf8_lossy(&plain.stdout) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn modules_command_curdir_suffix_mixed_case_dag_extension_directory_matches_plain_output() { + let root = unique_temp_dir("modules_curdir_suffix_mixed_case_dag_extension_directory"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory root"); + std::fs::write(dag_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in .DaG directory"); + + let plain = Command::new(daglang_bin()) + .arg("modules") + .arg("bundle.DaG") + .current_dir(&root) + .output() + .expect("failed to run modules on plain .DaG directory"); + assert!( + plain.status.success(), + "plain .DaG directory modules should succeed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("modules") + .arg("./bundle.DaG") + .current_dir(&root) + .output() + .expect("failed to run modules on curdir-suffix .DaG directory"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix .DaG directory modules should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix .DaG directory modules stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix .DaG directory modules stderr should match" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn modules_command_curdir_suffix_mixed_case_dag_extension_directory_with_errors_matches_plain_output( +) { + let root = unique_temp_dir("modules_curdir_suffix_mixed_case_dag_extension_directory_errors"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory root"); + let broken_file = dag_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in .DaG directory"); + + let plain = Command::new(daglang_bin()) + .arg("modules") + .arg("bundle.DaG") + .current_dir(&root) + .output() + .expect("failed to run modules on malformed plain .DaG directory"); + assert!( + plain.status.success(), + "malformed plain .DaG directory modules should succeed while reporting diagnostics: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("modules") + .arg("./bundle.DaG") + .current_dir(&root) + .output() + .expect("failed to run modules on malformed curdir-suffix .DaG directory"); + assert!( + curdir_suffix.status.success(), + "malformed curdir-suffix .DaG directory modules should succeed while reporting diagnostics: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "malformed plain and curdir-suffix .DaG directory modules stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "malformed plain and curdir-suffix .DaG directory modules stderr should match" + ); + let canonical_broken_file = broken_file + .canonicalize() + .expect("broken source should canonicalize"); + assert!( + String::from_utf8_lossy(&plain.stdout) + .contains(&format!("{}:2:3:", canonical_broken_file.display())), + "malformed .DaG directory diagnostics should include canonical broken-file path: {}", + String::from_utf8_lossy(&plain.stdout) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn modules_command_symlink_directory_named_dag_extension_matches_real_directory_output() { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("modules_symlink_directory_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in real directory"); symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); let symlink_dir = Command::new(daglang_bin()) @@ -17008,248 +17913,585 @@ fn modules_command_symlink_directory_named_mixed_case_dag_extension_with_errors_ std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[cfg(unix)] #[test] -fn modules_command_absolute_mixed_segment_root_matches_canonical_absolute_output() { - let cwd = workspace_root().join("core"); - let absolute_mixed = workspace_root().join(".").join("dsl"); - let absolute_canonical = workspace_root().join("dsl"); +fn modules_command_curdir_suffix_symlink_directory_named_dag_extension_matches_plain_symlink_output( +) { + use std::os::unix::fs::symlink; - let mixed = Command::new(daglang_bin()) + let root = unique_temp_dir("modules_curdir_suffix_symlink_directory_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + let plain = Command::new(daglang_bin()) .arg("modules") - .arg(&absolute_mixed) - .current_dir(&cwd) + .arg("link.dag") + .current_dir(&root) .output() - .expect("failed to run mixed-segment absolute-root daglang modules"); + .expect("failed to run modules on plain .dag directory symlink"); assert!( - mixed.status.success(), - "mixed-segment absolute-root modules should succeed: {}", - String::from_utf8_lossy(&mixed.stderr) + plain.status.success(), + "plain .dag directory symlink modules should succeed: {}", + String::from_utf8_lossy(&plain.stderr) ); - let canonical = Command::new(daglang_bin()) + let curdir_suffix = Command::new(daglang_bin()) .arg("modules") - .arg(&absolute_canonical) - .current_dir(&cwd) + .arg("./link.dag") + .current_dir(&root) .output() - .expect("failed to run canonical absolute-root daglang modules"); + .expect("failed to run modules on curdir-suffix .dag directory symlink"); assert!( - canonical.status.success(), - "canonical absolute-root modules should succeed: {}", - String::from_utf8_lossy(&canonical.stderr) + curdir_suffix.status.success(), + "curdir-suffix .dag directory symlink modules should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) ); assert_eq!( - mixed.stdout, canonical.stdout, - "mixed-segment and canonical absolute-root modules stdout should match" + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix .dag directory symlink modules stdout should match" ); assert_eq!( - mixed.stderr, canonical.stderr, - "mixed-segment and canonical absolute-root modules stderr should match" + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix .dag directory symlink modules stderr should match" ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[cfg(unix)] #[test] -fn modules_command_absolute_curdir_segment_trailing_slash_root_matches_canonical_absolute_output() { - let cwd = workspace_root().join("core"); - let absolute_curdir_segment_trailing = workspace_root().join("dsl/./"); - let absolute_canonical = workspace_root().join("dsl"); +fn modules_command_curdir_suffix_symlink_directory_named_dag_extension_with_errors_matches_plain_symlink_output( +) { + use std::os::unix::fs::symlink; - let curdir_segment_trailing = Command::new(daglang_bin()) + let root = + unique_temp_dir("modules_curdir_suffix_symlink_directory_named_dag_extension_errors"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + let broken_file = real_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + let plain = Command::new(daglang_bin()) .arg("modules") - .arg(&absolute_curdir_segment_trailing) - .current_dir(&cwd) + .arg("link.dag") + .current_dir(&root) .output() - .expect("failed to run curdir-segment-trailing absolute-root daglang modules"); + .expect("failed to run modules on malformed plain .dag directory symlink"); assert!( - curdir_segment_trailing.status.success(), - "curdir-segment-trailing absolute-root modules should succeed: {}", - String::from_utf8_lossy(&curdir_segment_trailing.stderr) + plain.status.success(), + "malformed plain .dag directory symlink modules should succeed while reporting diagnostics: {}", + String::from_utf8_lossy(&plain.stderr) ); - let canonical = Command::new(daglang_bin()) + let curdir_suffix = Command::new(daglang_bin()) .arg("modules") - .arg(&absolute_canonical) - .current_dir(&cwd) + .arg("./link.dag") + .current_dir(&root) .output() - .expect("failed to run canonical absolute-root daglang modules"); + .expect("failed to run modules on malformed curdir-suffix .dag directory symlink"); assert!( - canonical.status.success(), - "canonical absolute-root modules should succeed: {}", - String::from_utf8_lossy(&canonical.stderr) + curdir_suffix.status.success(), + "malformed curdir-suffix .dag directory symlink modules should succeed while reporting diagnostics: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) ); assert_eq!( - curdir_segment_trailing.stdout, canonical.stdout, - "curdir-segment-trailing and canonical absolute-root modules stdout should match" + plain.stdout, curdir_suffix.stdout, + "malformed plain and curdir-suffix .dag directory symlink modules stdout should match" ); assert_eq!( - curdir_segment_trailing.stderr, canonical.stderr, - "curdir-segment-trailing and canonical absolute-root modules stderr should match" + plain.stderr, curdir_suffix.stderr, + "malformed plain and curdir-suffix .dag directory symlink modules stderr should match" + ); + let canonical_broken_file = broken_file + .canonicalize() + .expect("broken source should canonicalize"); + assert!( + String::from_utf8_lossy(&plain.stdout) + .contains(&format!("{}:2:3:", canonical_broken_file.display())), + "malformed .dag directory symlink modules diagnostics should include canonical broken-file path: {}", + String::from_utf8_lossy(&plain.stdout) ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[cfg(unix)] #[test] -fn modules_command_absolute_curdir_suffix_root_matches_canonical_absolute_output() { - let cwd = workspace_root().join("core"); - let absolute_curdir_suffix = workspace_root().join("dsl/."); - let absolute_canonical = workspace_root().join("dsl"); +fn modules_command_curdir_suffix_symlink_directory_named_uppercase_dag_extension_matches_plain_symlink_output( +) { + use std::os::unix::fs::symlink; - let curdir_suffix = Command::new(daglang_bin()) + let root = + unique_temp_dir("modules_curdir_suffix_symlink_directory_named_uppercase_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DAG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DAG directory symlink"); + + let plain = Command::new(daglang_bin()) .arg("modules") - .arg(&absolute_curdir_suffix) - .current_dir(&cwd) + .arg("link.DAG") + .current_dir(&root) .output() - .expect("failed to run curdir-suffix absolute-root daglang modules"); + .expect("failed to run modules on plain .DAG directory symlink"); assert!( - curdir_suffix.status.success(), - "curdir-suffix absolute-root modules should succeed: {}", - String::from_utf8_lossy(&curdir_suffix.stderr) + plain.status.success(), + "plain .DAG directory symlink modules should succeed: {}", + String::from_utf8_lossy(&plain.stderr) ); - let canonical = Command::new(daglang_bin()) + let curdir_suffix = Command::new(daglang_bin()) .arg("modules") - .arg(&absolute_canonical) - .current_dir(&cwd) + .arg("./link.DAG") + .current_dir(&root) .output() - .expect("failed to run canonical absolute-root daglang modules"); + .expect("failed to run modules on curdir-suffix .DAG directory symlink"); assert!( - canonical.status.success(), - "canonical absolute-root modules should succeed: {}", - String::from_utf8_lossy(&canonical.stderr) + curdir_suffix.status.success(), + "curdir-suffix .DAG directory symlink modules should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) ); assert_eq!( - curdir_suffix.stdout, canonical.stdout, - "curdir-suffix and canonical absolute-root modules stdout should match" + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix .DAG directory symlink modules stdout should match" ); assert_eq!( - curdir_suffix.stderr, canonical.stderr, - "curdir-suffix and canonical absolute-root modules stderr should match" + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix .DAG directory symlink modules stderr should match" ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[cfg(unix)] #[test] -fn modules_command_absolute_curdir_suffix_double_separator_root_matches_canonical_absolute_output() { - let cwd = workspace_root().join("core"); - let absolute_curdir_suffix_double_separator = - PathBuf::from(format!("{}//.", workspace_root().join("dsl").display())); - let absolute_canonical = workspace_root().join("dsl"); +fn modules_command_curdir_suffix_symlink_directory_named_uppercase_dag_extension_with_errors_matches_plain_symlink_output( +) { + use std::os::unix::fs::symlink; - let curdir_suffix_double_separator = Command::new(daglang_bin()) + let root = unique_temp_dir( + "modules_curdir_suffix_symlink_directory_named_uppercase_dag_extension_errors", + ); + let real_dir = root.join("real"); + let link_dir = root.join("link.DAG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + let broken_file = real_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DAG directory symlink"); + + let plain = Command::new(daglang_bin()) .arg("modules") - .arg(&absolute_curdir_suffix_double_separator) - .current_dir(&cwd) + .arg("link.DAG") + .current_dir(&root) .output() - .expect("failed to run curdir-suffix-double-separator absolute-root daglang modules"); + .expect("failed to run modules on malformed plain .DAG directory symlink"); assert!( - curdir_suffix_double_separator.status.success(), - "curdir-suffix-double-separator absolute-root modules should succeed: {}", - String::from_utf8_lossy(&curdir_suffix_double_separator.stderr) + plain.status.success(), + "malformed plain .DAG directory symlink modules should succeed while reporting diagnostics: {}", + String::from_utf8_lossy(&plain.stderr) ); - let canonical = Command::new(daglang_bin()) + let curdir_suffix = Command::new(daglang_bin()) .arg("modules") - .arg(&absolute_canonical) - .current_dir(&cwd) + .arg("./link.DAG") + .current_dir(&root) .output() - .expect("failed to run canonical absolute-root daglang modules"); + .expect("failed to run modules on malformed curdir-suffix .DAG directory symlink"); assert!( - canonical.status.success(), - "canonical absolute-root modules should succeed: {}", - String::from_utf8_lossy(&canonical.stderr) + curdir_suffix.status.success(), + "malformed curdir-suffix .DAG directory symlink modules should succeed while reporting diagnostics: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) ); assert_eq!( - curdir_suffix_double_separator.stdout, canonical.stdout, - "curdir-suffix-double-separator and canonical absolute-root modules stdout should match" + plain.stdout, curdir_suffix.stdout, + "malformed plain and curdir-suffix .DAG directory symlink modules stdout should match" ); assert_eq!( - curdir_suffix_double_separator.stderr, canonical.stderr, - "curdir-suffix-double-separator and canonical absolute-root modules stderr should match" + plain.stderr, curdir_suffix.stderr, + "malformed plain and curdir-suffix .DAG directory symlink modules stderr should match" + ); + let canonical_broken_file = broken_file + .canonicalize() + .expect("broken source should canonicalize"); + assert!( + String::from_utf8_lossy(&plain.stdout) + .contains(&format!("{}:2:3:", canonical_broken_file.display())), + "malformed .DAG directory symlink modules diagnostics should include canonical broken-file path: {}", + String::from_utf8_lossy(&plain.stdout) ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[cfg(unix)] #[test] -fn modules_command_absolute_curdir_segment_suffix_root_matches_canonical_absolute_output() { - let cwd = workspace_root().join("core"); - let absolute_curdir_segment_suffix = workspace_root().join("dsl/./."); - let absolute_canonical = workspace_root().join("dsl"); +fn modules_command_curdir_suffix_symlink_directory_named_mixed_case_dag_extension_matches_plain_symlink_output( +) { + use std::os::unix::fs::symlink; - let curdir_segment_suffix = Command::new(daglang_bin()) + let root = + unique_temp_dir("modules_curdir_suffix_symlink_directory_named_mixed_case_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DaG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("main.dag"), "module sample.main\nfn ok() -> Unit {}") + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DaG directory symlink"); + + let plain = Command::new(daglang_bin()) .arg("modules") - .arg(&absolute_curdir_segment_suffix) - .current_dir(&cwd) + .arg("link.DaG") + .current_dir(&root) .output() - .expect("failed to run curdir-segment-suffix absolute-root daglang modules"); + .expect("failed to run modules on plain .DaG directory symlink"); assert!( - curdir_segment_suffix.status.success(), - "curdir-segment-suffix absolute-root modules should succeed: {}", - String::from_utf8_lossy(&curdir_segment_suffix.stderr) + plain.status.success(), + "plain .DaG directory symlink modules should succeed: {}", + String::from_utf8_lossy(&plain.stderr) ); - let canonical = Command::new(daglang_bin()) + let curdir_suffix = Command::new(daglang_bin()) .arg("modules") - .arg(&absolute_canonical) - .current_dir(&cwd) + .arg("./link.DaG") + .current_dir(&root) .output() - .expect("failed to run canonical absolute-root daglang modules"); + .expect("failed to run modules on curdir-suffix .DaG directory symlink"); assert!( - canonical.status.success(), - "canonical absolute-root modules should succeed: {}", - String::from_utf8_lossy(&canonical.stderr) + curdir_suffix.status.success(), + "curdir-suffix .DaG directory symlink modules should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) ); assert_eq!( - curdir_segment_suffix.stdout, canonical.stdout, - "curdir-segment-suffix and canonical absolute-root modules stdout should match" + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix .DaG directory symlink modules stdout should match" ); assert_eq!( - curdir_segment_suffix.stderr, canonical.stderr, - "curdir-segment-suffix and canonical absolute-root modules stderr should match" + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix .DaG directory symlink modules stderr should match" ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[cfg(unix)] #[test] -fn modules_command_absolute_curdir_segment_double_separator_suffix_root_matches_canonical_absolute_output( +fn modules_command_curdir_suffix_symlink_directory_named_mixed_case_dag_extension_with_errors_matches_plain_symlink_output( ) { - let cwd = workspace_root().join("core"); - let absolute_curdir_segment_double_separator_suffix = - PathBuf::from(format!("{}//./.", workspace_root().join("dsl").display())); - let absolute_canonical = workspace_root().join("dsl"); + use std::os::unix::fs::symlink; - let curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + let root = unique_temp_dir( + "modules_curdir_suffix_symlink_directory_named_mixed_case_dag_extension_errors", + ); + let real_dir = root.join("real"); + let link_dir = root.join("link.DaG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + let broken_file = real_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DaG directory symlink"); + + let plain = Command::new(daglang_bin()) .arg("modules") - .arg(&absolute_curdir_segment_double_separator_suffix) - .current_dir(&cwd) + .arg("link.DaG") + .current_dir(&root) .output() - .expect( - "failed to run curdir-segment-double-separator-suffix absolute-root daglang modules", - ); + .expect("failed to run modules on malformed plain .DaG directory symlink"); assert!( - curdir_segment_double_separator_suffix.status.success(), - "curdir-segment-double-separator-suffix absolute-root modules should succeed: {}", - String::from_utf8_lossy(&curdir_segment_double_separator_suffix.stderr) + plain.status.success(), + "malformed plain .DaG directory symlink modules should succeed while reporting diagnostics: {}", + String::from_utf8_lossy(&plain.stderr) ); - let canonical = Command::new(daglang_bin()) + let curdir_suffix = Command::new(daglang_bin()) .arg("modules") - .arg(&absolute_canonical) - .current_dir(&cwd) + .arg("./link.DaG") + .current_dir(&root) .output() - .expect("failed to run canonical absolute-root daglang modules"); + .expect("failed to run modules on malformed curdir-suffix .DaG directory symlink"); assert!( - canonical.status.success(), - "canonical absolute-root modules should succeed: {}", - String::from_utf8_lossy(&canonical.stderr) + curdir_suffix.status.success(), + "malformed curdir-suffix .DaG directory symlink modules should succeed while reporting diagnostics: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) ); assert_eq!( - curdir_segment_double_separator_suffix.stdout, canonical.stdout, - "curdir-segment-double-separator-suffix and canonical absolute-root modules stdout should match" + plain.stdout, curdir_suffix.stdout, + "malformed plain and curdir-suffix .DaG directory symlink modules stdout should match" ); assert_eq!( - curdir_segment_double_separator_suffix.stderr, canonical.stderr, - "curdir-segment-double-separator-suffix and canonical absolute-root modules stderr should match" + plain.stderr, curdir_suffix.stderr, + "malformed plain and curdir-suffix .DaG directory symlink modules stderr should match" + ); + let canonical_broken_file = broken_file + .canonicalize() + .expect("broken source should canonicalize"); + assert!( + String::from_utf8_lossy(&plain.stdout) + .contains(&format!("{}:2:3:", canonical_broken_file.display())), + "malformed .DaG directory symlink modules diagnostics should include canonical broken-file path: {}", + String::from_utf8_lossy(&plain.stdout) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn modules_command_absolute_mixed_segment_root_matches_canonical_absolute_output() { + let cwd = workspace_root().join("core"); + let absolute_mixed = workspace_root().join(".").join("dsl"); + let absolute_canonical = workspace_root().join("dsl"); + + let mixed = Command::new(daglang_bin()) + .arg("modules") + .arg(&absolute_mixed) + .current_dir(&cwd) + .output() + .expect("failed to run mixed-segment absolute-root daglang modules"); + assert!( + mixed.status.success(), + "mixed-segment absolute-root modules should succeed: {}", + String::from_utf8_lossy(&mixed.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("modules") + .arg(&absolute_canonical) + .current_dir(&cwd) + .output() + .expect("failed to run canonical absolute-root daglang modules"); + assert!( + canonical.status.success(), + "canonical absolute-root modules should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + mixed.stdout, canonical.stdout, + "mixed-segment and canonical absolute-root modules stdout should match" + ); + assert_eq!( + mixed.stderr, canonical.stderr, + "mixed-segment and canonical absolute-root modules stderr should match" + ); +} + +#[test] +fn modules_command_absolute_curdir_segment_trailing_slash_root_matches_canonical_absolute_output() { + let cwd = workspace_root().join("core"); + let absolute_curdir_segment_trailing = workspace_root().join("dsl/./"); + let absolute_canonical = workspace_root().join("dsl"); + + let curdir_segment_trailing = Command::new(daglang_bin()) + .arg("modules") + .arg(&absolute_curdir_segment_trailing) + .current_dir(&cwd) + .output() + .expect("failed to run curdir-segment-trailing absolute-root daglang modules"); + assert!( + curdir_segment_trailing.status.success(), + "curdir-segment-trailing absolute-root modules should succeed: {}", + String::from_utf8_lossy(&curdir_segment_trailing.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("modules") + .arg(&absolute_canonical) + .current_dir(&cwd) + .output() + .expect("failed to run canonical absolute-root daglang modules"); + assert!( + canonical.status.success(), + "canonical absolute-root modules should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_segment_trailing.stdout, canonical.stdout, + "curdir-segment-trailing and canonical absolute-root modules stdout should match" + ); + assert_eq!( + curdir_segment_trailing.stderr, canonical.stderr, + "curdir-segment-trailing and canonical absolute-root modules stderr should match" + ); +} + +#[test] +fn modules_command_absolute_curdir_suffix_root_matches_canonical_absolute_output() { + let cwd = workspace_root().join("core"); + let absolute_curdir_suffix = workspace_root().join("dsl/."); + let absolute_canonical = workspace_root().join("dsl"); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("modules") + .arg(&absolute_curdir_suffix) + .current_dir(&cwd) + .output() + .expect("failed to run curdir-suffix absolute-root daglang modules"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix absolute-root modules should succeed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("modules") + .arg(&absolute_canonical) + .current_dir(&cwd) + .output() + .expect("failed to run canonical absolute-root daglang modules"); + assert!( + canonical.status.success(), + "canonical absolute-root modules should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_suffix.stdout, canonical.stdout, + "curdir-suffix and canonical absolute-root modules stdout should match" + ); + assert_eq!( + curdir_suffix.stderr, canonical.stderr, + "curdir-suffix and canonical absolute-root modules stderr should match" + ); +} + +#[test] +fn modules_command_absolute_curdir_suffix_double_separator_root_matches_canonical_absolute_output() { + let cwd = workspace_root().join("core"); + let absolute_curdir_suffix_double_separator = + PathBuf::from(format!("{}//.", workspace_root().join("dsl").display())); + let absolute_canonical = workspace_root().join("dsl"); + + let curdir_suffix_double_separator = Command::new(daglang_bin()) + .arg("modules") + .arg(&absolute_curdir_suffix_double_separator) + .current_dir(&cwd) + .output() + .expect("failed to run curdir-suffix-double-separator absolute-root daglang modules"); + assert!( + curdir_suffix_double_separator.status.success(), + "curdir-suffix-double-separator absolute-root modules should succeed: {}", + String::from_utf8_lossy(&curdir_suffix_double_separator.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("modules") + .arg(&absolute_canonical) + .current_dir(&cwd) + .output() + .expect("failed to run canonical absolute-root daglang modules"); + assert!( + canonical.status.success(), + "canonical absolute-root modules 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 modules stdout should match" + ); + assert_eq!( + curdir_suffix_double_separator.stderr, canonical.stderr, + "curdir-suffix-double-separator and canonical absolute-root modules stderr should match" + ); +} + +#[test] +fn modules_command_absolute_curdir_segment_suffix_root_matches_canonical_absolute_output() { + let cwd = workspace_root().join("core"); + let absolute_curdir_segment_suffix = workspace_root().join("dsl/./."); + let absolute_canonical = workspace_root().join("dsl"); + + let curdir_segment_suffix = Command::new(daglang_bin()) + .arg("modules") + .arg(&absolute_curdir_segment_suffix) + .current_dir(&cwd) + .output() + .expect("failed to run curdir-segment-suffix absolute-root daglang modules"); + assert!( + curdir_segment_suffix.status.success(), + "curdir-segment-suffix absolute-root modules should succeed: {}", + String::from_utf8_lossy(&curdir_segment_suffix.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("modules") + .arg(&absolute_canonical) + .current_dir(&cwd) + .output() + .expect("failed to run canonical absolute-root daglang modules"); + assert!( + canonical.status.success(), + "canonical absolute-root modules should succeed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + assert_eq!( + curdir_segment_suffix.stdout, canonical.stdout, + "curdir-segment-suffix and canonical absolute-root modules stdout should match" + ); + assert_eq!( + curdir_segment_suffix.stderr, canonical.stderr, + "curdir-segment-suffix and canonical absolute-root modules stderr should match" + ); +} + +#[test] +fn modules_command_absolute_curdir_segment_double_separator_suffix_root_matches_canonical_absolute_output( +) { + let cwd = workspace_root().join("core"); + let absolute_curdir_segment_double_separator_suffix = + PathBuf::from(format!("{}//./.", workspace_root().join("dsl").display())); + let absolute_canonical = workspace_root().join("dsl"); + + let curdir_segment_double_separator_suffix = Command::new(daglang_bin()) + .arg("modules") + .arg(&absolute_curdir_segment_double_separator_suffix) + .current_dir(&cwd) + .output() + .expect( + "failed to run curdir-segment-double-separator-suffix absolute-root daglang modules", + ); + assert!( + curdir_segment_double_separator_suffix.status.success(), + "curdir-segment-double-separator-suffix absolute-root modules should succeed: {}", + String::from_utf8_lossy(&curdir_segment_double_separator_suffix.stderr) + ); + + let canonical = Command::new(daglang_bin()) + .arg("modules") + .arg(&absolute_canonical) + .current_dir(&cwd) + .output() + .expect("failed to run canonical absolute-root daglang modules"); + assert!( + canonical.status.success(), + "canonical absolute-root modules 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 modules stdout should match" + ); + assert_eq!( + curdir_segment_double_separator_suffix.stderr, canonical.stderr, + "curdir-segment-double-separator-suffix and canonical absolute-root modules stderr should match" ); } @@ -26660,7 +27902,7 @@ fn modules_command_relative_and_absolute_non_directory_roots_are_equivalent() { } #[test] -fn viz_self_renders_pipeline_ascii_by_default() { +fn viz_self_renders_pipeline_mermaid() { let output = Command::new(daglang_bin()) .arg("viz") .arg("--self") @@ -26676,7 +27918,7 @@ fn viz_self_renders_pipeline_ascii_by_default() { 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("ASCII DAG: daglang-compiler-pipeline")); + assert!(stdout.contains("flowchart TB")); assert!(stdout.contains("discover_files")); assert!(stdout.contains("report_modules")); } @@ -26717,15 +27959,15 @@ fn viz_self_contains_expected_pipeline_edge_labels() { assert!(output.status.success(), "viz --self should succeed"); let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("discover_files.files -> parse_all.files"), + stdout.contains("files:files"), "viz --self should include discover->parse files edge label" ); assert!( - stdout.contains("discover_files.diagnostics -> parse_all.diagnostics"), + stdout.contains("diagnostics:diagnostics"), "viz --self should include diagnostics flow edges" ); assert!( - stdout.contains("build_module_graph.module_graph -> report_modules.module_graph"), + stdout.contains("module_graph:module_graph"), "viz --self should include build->report module graph edge label" ); } @@ -26734,8 +27976,6 @@ fn viz_self_contains_expected_pipeline_edge_labels() { fn viz_self_matches_expected_mermaid_snapshot() { let output = Command::new(daglang_bin()) .arg("viz") - .arg("--format") - .arg("mermaid") .arg("--self") .current_dir(workspace_root()) .output() @@ -26746,20 +27986,6 @@ fn viz_self_matches_expected_mermaid_snapshot() { assert_eq!(stdout, expected_viz_self_mermaid()); } -#[test] -fn viz_self_matches_expected_ascii_snapshot() { - let output = Command::new(daglang_bin()) - .arg("viz") - .arg("--self") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang viz --self"); - - assert!(output.status.success(), "viz --self should succeed"); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout, expected_viz_self_ascii()); -} - #[test] fn check_command_reports_file_line_col_for_broken_file() { let broken_file = unique_temp_file("broken"); @@ -30189,21 +31415,13 @@ fn no_command_exits_nonzero_with_usage_message() { "usage guidance should include check command help text: {stderr}" ); assert!( - stderr.contains("obligations [--format text|json] "), + stderr.contains("obligations [--format text|json]"), "usage guidance should include obligations command help text: {stderr}" ); assert!( - stderr.contains("show-triplets [--format text|json] "), + stderr.contains("show-triplets [--format text|json]"), "usage guidance should include show-triplets command help text: {stderr}" ); - assert!( - stderr.contains("run [--output ] [--dry-run|--check-mode]"), - "usage guidance should include run command help text: {stderr}" - ); - assert!( - stderr.contains("viz [--format ascii|mermaid] |--self"), - "usage guidance should include viz format help text: {stderr}" - ); } #[test] @@ -30279,7 +31497,7 @@ fn viz_self_with_extra_args_exits_nonzero_with_usage_message() { ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Usage: daglang viz [--format ascii|mermaid] |--self"), + stderr.contains("Usage: daglang viz |viz --self"), "viz --self with extra args should print command usage: {stderr}" ); } @@ -30303,14 +31521,14 @@ fn viz_without_args_exits_nonzero_with_usage_message() { ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Usage: daglang viz [--format ascii|mermaid] |--self"), + stderr.contains("Usage: daglang viz |viz --self"), "viz without args should print usage guidance: {stderr}" ); } #[test] -fn compile_read_commands_without_required_target_exit_with_usage_message() { - for command in ["expand", "manifest", "obligations", "show-triplets"] { +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()) @@ -30326,26 +31544,16 @@ fn compile_read_commands_without_required_target_exit_with_usage_message() { "{command} without required target should use usage exit code 1" ); let stderr = String::from_utf8_lossy(&output.stderr); - let expected_usage = match command { - "manifest" => "Usage: daglang manifest [--format text|json] ".to_string(), - "obligations" => { - "Usage: daglang obligations [--format text|json] ".to_string() - } - "show-triplets" => { - "Usage: daglang show-triplets [--format text|json] ".to_string() - } - _ => format!("Usage: daglang {command} "), - }; assert!( - stderr.contains(&expected_usage), + stderr.contains(&format!("Usage: daglang {command} ")), "{command} without required target should print command usage: {stderr}" ); } } #[test] -fn compile_read_commands_with_extra_args_exit_with_usage_message() { - for command in ["expand", "manifest", "obligations", "show-triplets"] { +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") @@ -30363,3909 +31571,389 @@ fn compile_read_commands_with_extra_args_exit_with_usage_message() { "{command} with extra args should use usage exit code 1" ); let stderr = String::from_utf8_lossy(&output.stderr); - let expected_usage = match command { - "manifest" => "Usage: daglang manifest [--format text|json] ".to_string(), - "obligations" => { - "Usage: daglang obligations [--format text|json] ".to_string() - } - "show-triplets" => { - "Usage: daglang show-triplets [--format text|json] ".to_string() - } - _ => format!("Usage: daglang {command} "), - }; assert!( - stderr.contains(&expected_usage), + stderr.contains(&format!("Usage: daglang {command} ")), "{command} with extra args should print command usage: {stderr}" ); } } #[test] -fn manifest_with_unknown_format_exits_nonzero_with_clear_error() { - let output = Command::new(daglang_bin()) - .arg("manifest") - .arg("--format") - .arg("yaml") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang manifest with unknown format"); - - assert!( - !output.status.success(), - "manifest with unknown format should fail" - ); - assert_eq!( - output.status.code(), - Some(1), - "manifest with unknown format should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unknown manifest format `yaml`"), - "manifest should report unknown format explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang manifest [--format text|json] "), - "manifest should print usage guidance for unknown format: {stderr}" - ); +fn obligations_and_show_triplets_without_required_target_exit_with_usage_message() { + for (command, usage) in [ + ( + "obligations", + "Usage: daglang obligations [--format text|json]", + ), + ( + "show-triplets", + "Usage: daglang show-triplets [--format text|json]", + ), + ] { + let output = Command::new(daglang_bin()) + .arg(command) + .current_dir(workspace_root()) + .output() + .unwrap_or_else(|err| panic!("failed to run {command} without target: {err}")); + assert!( + !output.status.success(), + "{command} without required target should fail" + ); + assert_eq!( + output.status.code(), + Some(1), + "{command} without required target should use usage exit code 1" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(usage), + "{command} without required target should print command usage: {stderr}" + ); + } } #[test] -fn obligations_with_unknown_format_exits_nonzero_with_clear_error() { - let output = Command::new(daglang_bin()) - .arg("obligations") - .arg("--format") - .arg("yaml") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang obligations with unknown format"); +fn obligations_and_show_triplets_with_invalid_format_exit_with_usage_message() { + for (command, usage) in [ + ( + "obligations", + "Usage: daglang obligations [--format text|json]", + ), + ( + "show-triplets", + "Usage: daglang show-triplets [--format text|json]", + ), + ] { + for bad_value in ["yaml", "JSON", "Json", "Text"] { + let output = Command::new(daglang_bin()) + .arg(command) + .arg("dsl/tools/makegen.dag") + .arg("--format") + .arg(bad_value) + .current_dir(workspace_root()) + .output() + .unwrap_or_else(|err| { + panic!("failed to run {command} with invalid format {bad_value}: {err}") + }); + assert!( + !output.status.success(), + "{command} with invalid format {bad_value} should fail" + ); + assert_eq!( + output.status.code(), + Some(1), + "{command} with invalid format {bad_value} should use usage exit code 1" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(usage), + "{command} with invalid format {bad_value} should print command usage: {stderr}" + ); + } + } +} - assert!( - !output.status.success(), - "obligations with unknown format should fail" - ); - assert_eq!( - output.status.code(), - Some(1), - "obligations with unknown format should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unknown obligations format `yaml`"), - "obligations should report unknown format explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang obligations [--format text|json] "), - "obligations should print usage guidance for unknown format: {stderr}" - ); +#[test] +fn obligations_and_show_triplets_with_extra_args_exit_with_usage_message() { + for (command, usage) in [ + ( + "obligations", + "Usage: daglang obligations [--format text|json]", + ), + ( + "show-triplets", + "Usage: daglang show-triplets [--format text|json]", + ), + ] { + let output = Command::new(daglang_bin()) + .arg(command) + .arg("dsl/tools/makegen.dag") + .arg("--format") + .arg("text") + .arg("extra") + .current_dir(workspace_root()) + .output() + .unwrap_or_else(|err| panic!("failed to run {command} with extra args: {err}")); + assert!( + !output.status.success(), + "{command} with extra args should fail" + ); + assert_eq!( + output.status.code(), + Some(1), + "{command} with extra args should use usage exit code 1" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(usage), + "{command} with extra args should print command usage: {stderr}" + ); + } } #[test] -fn show_triplets_with_unknown_format_exits_nonzero_with_clear_error() { - let output = Command::new(daglang_bin()) - .arg("show-triplets") - .arg("--format") - .arg("yaml") - .arg("dsl/tools/makegen.dag") +fn obligations_and_show_triplets_with_missing_format_value_exit_with_usage_message() { + for (command, usage) in [ + ( + "obligations", + "Usage: daglang obligations [--format text|json]", + ), + ( + "show-triplets", + "Usage: daglang show-triplets [--format text|json]", + ), + ] { + let output = Command::new(daglang_bin()) + .arg(command) + .arg("dsl/tools/makegen.dag") + .arg("--format") + .current_dir(workspace_root()) + .output() + .unwrap_or_else(|err| panic!("failed to run {command} with missing format value: {err}")); + assert!( + !output.status.success(), + "{command} with missing format value should fail" + ); + assert_eq!( + output.status.code(), + Some(1), + "{command} with missing format value should use usage exit code 1" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(usage), + "{command} with missing format value should print command usage: {stderr}" + ); + } +} + +fn run_compile_family_command(command: &str, target: &str, trailing_args: &[&str]) -> Output { + Command::new(daglang_bin()) + .arg(command) + .arg(target) + .args(trailing_args) .current_dir(workspace_root()) .output() - .expect("failed to run daglang show-triplets with unknown format"); + .unwrap_or_else(|err| panic!("failed to run {command} for target {target}: {err}")) +} +fn assert_compile_family_command_succeeds( + command: &str, + target: &str, + target_label: &str, + trailing_args: &[&str], +) -> Output { + let output = run_compile_family_command(command, target, trailing_args); assert!( - !output.status.success(), - "show-triplets with unknown format should fail" - ); - assert_eq!( - output.status.code(), - Some(1), - "show-triplets with unknown format should use usage exit code 1" + output.status.success(), + "{command} should execute successfully for {target_label} makegen fixture target" ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("unknown show-triplets format `yaml`"), - "show-triplets should report unknown format explicitly: {stderr}" + !stderr.contains("TODO"), + "{command} should not emit TODO placeholder output: {stderr}" ); assert!( - stderr.contains("Usage: daglang show-triplets [--format text|json] "), - "show-triplets should print usage guidance for unknown format: {stderr}" + !output.stdout.is_empty(), + "{command} should emit meaningful stdout output for {target_label} target" ); + output } -#[test] -fn viz_with_unknown_format_exits_nonzero_with_clear_error_for_self_target() { - let output = Command::new(daglang_bin()) - .arg("viz") - .arg("--format") - .arg("yaml") - .arg("--self") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang viz with unknown format"); +const COMPILE_FAMILY_COMMANDS: [(&str, &[&str]); 5] = [ + ("expand", &[]), + ("manifest", &[]), + ("compile", &[]), + ("obligations", &["--format", "json"]), + ("show-triplets", &["--format", "json"]), +]; - assert!( - !output.status.success(), - "viz with unknown format should fail" - ); - assert_eq!( - output.status.code(), - Some(1), - "viz with unknown format should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unknown viz format `yaml`"), - "viz should report unknown format explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang viz [--format ascii|mermaid] |--self"), - "viz should print usage guidance for unknown format: {stderr}" - ); +fn run_compile_family_smoke_for_target(target_label: &str, target: &str) { + for (command, trailing_args) in COMPILE_FAMILY_COMMANDS { + assert_compile_family_command_succeeds(command, target, target_label, trailing_args); + } +} + +fn makegen_target_variants() -> Vec<(&'static str, String)> { + vec![ + ("relative", "dsl/tools/makegen.dag".to_string()), + ( + "absolute", + workspace_root() + .join("dsl/tools/makegen.dag") + .to_string_lossy() + .into_owned(), + ), + ("curdir-suffix", "./dsl/tools/makegen.dag".to_string()), + ( + "absolute-curdir-segment", + workspace_root() + .join("./dsl/./tools/../tools/makegen.dag") + .to_string_lossy() + .into_owned(), + ), + ( + "absolute-double-separator", + format!("{}/dsl//tools///makegen.dag", workspace_root().display()), + ), + ( + "absolute-parent-segment", + workspace_root() + .join("dsl/../dsl/tools/makegen.dag") + .to_string_lossy() + .into_owned(), + ), + ( + "absolute-parent-double-separator", + format!("{}/dsl/..//dsl/tools/makegen.dag", workspace_root().display()), + ), + ( + "absolute-parent-curdir-segment", + format!( + "{}/dsl/tools/./../tools/makegen.dag", + workspace_root().display() + ), + ), + ( + "absolute-parent-curdir-double-separator", + format!( + "{}/dsl/./tools/..//tools/makegen.dag", + workspace_root().display() + ), + ), + ] } #[test] -fn viz_with_unknown_format_exits_nonzero_with_clear_error() { - let output = Command::new(daglang_bin()) - .arg("viz") - .arg("--format") - .arg("yaml") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang viz with unknown format"); +fn compile_family_commands_execute_real_pipeline_paths() { + run_compile_family_smoke_for_target("relative", "dsl/tools/makegen.dag"); +} - assert!( - !output.status.success(), - "viz with unknown format should fail" - ); - assert_eq!( - output.status.code(), - Some(1), - "viz with unknown format should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unknown viz format `yaml`"), - "viz should report unknown format explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang viz [--format ascii|mermaid] |--self"), - "viz should print usage guidance for unknown format: {stderr}" - ); +#[test] +fn compile_family_commands_execute_real_pipeline_paths_with_absolute_target() { + let absolute_target = workspace_root().join("dsl/tools/makegen.dag"); + let absolute_target = absolute_target.to_string_lossy().into_owned(); + run_compile_family_smoke_for_target("absolute", &absolute_target); } #[test] -fn run_without_args_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run without args"); +fn compile_family_commands_execute_real_pipeline_paths_with_curdir_suffix_target() { + run_compile_family_smoke_for_target("curdir-suffix", "./dsl/tools/makegen.dag"); +} - assert!( - !output.status.success(), - "run without args should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run without args should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run without args should print command usage: {stderr}" +#[test] +fn compile_family_commands_execute_real_pipeline_paths_with_absolute_curdir_segment_target() { + let absolute_target_with_curdir_segment = + workspace_root().join("./dsl/./tools/../tools/makegen.dag"); + let absolute_target_with_curdir_segment = + absolute_target_with_curdir_segment.to_string_lossy().into_owned(); + run_compile_family_smoke_for_target( + "absolute-curdir-segment", + &absolute_target_with_curdir_segment, ); } #[test] -fn run_with_unknown_flag_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--mystery") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with unknown flag"); - - assert!( - !output.status.success(), - "run with unknown flag should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with unknown flag should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unknown run flag `--mystery`"), - "run should report unknown flag explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for unknown flags: {stderr}" +fn compile_family_commands_execute_real_pipeline_paths_with_absolute_double_separator_target() { + let absolute_target_with_double_separators = + format!("{}/dsl//tools///makegen.dag", workspace_root().display()); + run_compile_family_smoke_for_target( + "absolute-double-separator", + &absolute_target_with_double_separators, ); } #[test] -fn run_with_unknown_flag_after_input_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--mystery") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with unknown flag after input"); - - assert!( - !output.status.success(), - "run with unknown flag after input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with unknown flag after input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unknown run flag `--mystery`"), - "run should report unknown flag after input explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for unknown flags after input: {stderr}" +fn compile_family_commands_execute_real_pipeline_paths_with_absolute_parent_segment_target() { + let absolute_target_with_parent_segment = workspace_root() + .join("dsl/../dsl/tools/makegen.dag") + .to_string_lossy() + .into_owned(); + run_compile_family_smoke_for_target( + "absolute-parent-segment", + &absolute_target_with_parent_segment, ); } #[test] -fn run_supports_double_dash_for_dash_prefixed_input_paths() { - let input_path = std::env::temp_dir().join(format!( - "--daglang_run_dash_prefixed_input_{}_{}.dag", - 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::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect("failed to copy makegen fixture for dash-prefixed input"); - let output_path = unique_temp_output_file("run_dash_prefixed_input_output"); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale output for dash-prefixed input test"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--dry-run") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with dash-prefixed input path"); - - assert!( - output.status.success(), - "run should accept dash-prefixed .dag inputs after -- separator: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "dash-prefixed run should execute in dry-run mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "dash-prefixed dry-run should report no writes: {stdout}" - ); - assert!( - !output_path.exists(), - "dash-prefixed dry-run should not create output file" +fn compile_family_commands_execute_real_pipeline_paths_with_absolute_parent_double_separator_target() +{ + let absolute_target_with_parent_double_separator = + format!("{}/dsl/..//dsl/tools/makegen.dag", workspace_root().display()); + run_compile_family_smoke_for_target( + "absolute-parent-double-separator", + &absolute_target_with_parent_double_separator, ); +} - std::fs::remove_file(&input_path).expect("failed to clean dash-prefixed input fixture"); +#[test] +fn compile_family_commands_execute_real_pipeline_paths_with_absolute_parent_curdir_segment_target() { + let absolute_target_with_parent_curdir_segment = + format!("{}/dsl/tools/./../tools/makegen.dag", workspace_root().display()); + run_compile_family_smoke_for_target( + "absolute-parent-curdir-segment", + &absolute_target_with_parent_curdir_segment, + ); } #[test] -fn run_supports_double_dash_for_dash_prefixed_uppercase_dag_input_paths() { - let input_path = std::env::temp_dir().join(format!( - "--daglang_run_dash_prefixed_uppercase_input_{}_{}.DAG", - 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::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect("failed to copy makegen fixture for uppercase dash-prefixed input"); - let output_path = unique_temp_output_file("run_dash_prefixed_uppercase_input_output"); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale output for uppercase dash-prefixed input test"); +fn compile_family_commands_execute_real_pipeline_paths_with_absolute_parent_curdir_double_separator_target( +) { + let absolute_target_with_parent_curdir_double_separator = + format!("{}/dsl/./tools/..//tools/makegen.dag", workspace_root().display()); + run_compile_family_smoke_for_target( + "absolute-parent-curdir-double-separator", + &absolute_target_with_parent_curdir_double_separator, + ); +} + +#[test] +fn compile_family_commands_makegen_target_variants_are_output_equivalent() { + let targets = makegen_target_variants(); + + for (command, trailing_args) in COMPILE_FAMILY_COMMANDS { + let mut runs: Vec<(&str, Output)> = Vec::with_capacity(targets.len()); + for (target_label, target_value) in targets.iter() { + let output = assert_compile_family_command_succeeds( + command, + target_value, + target_label, + trailing_args, + ); + runs.push((target_label, output)); + } + + let (_, reference_output) = &runs[0]; + for (target_label, output) in runs.iter().skip(1) { + assert_eq!( + reference_output.stdout, output.stdout, + "{command} stdout should match between relative and {target_label} makegen targets" + ); + assert_eq!( + reference_output.stderr, output.stderr, + "{command} stderr should match between relative and {target_label} makegen targets" + ); + } } +} +#[test] +fn viz_without_self_emits_compiled_mermaid_graph() { let output = Command::new(daglang_bin()) - .arg("run") - .arg("--dry-run") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) + .arg("viz") + .arg("dsl/tools/makegen.dag") .current_dir(workspace_root()) .output() - .expect("failed to run daglang run with uppercase dash-prefixed input"); + .expect("failed to run daglang viz"); assert!( output.status.success(), - "run with uppercase dash-prefixed .DAG input should succeed: {}", - String::from_utf8_lossy(&output.stderr) + "viz should compile and render mermaid output" ); let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("mode=dry-run"), - "uppercase dash-prefixed run should execute in dry-run mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "uppercase dash-prefixed dry-run should report no writes: {stdout}" - ); - assert!( - !output_path.exists(), - "uppercase dash-prefixed dry-run should not create output file" - ); - - std::fs::remove_file(&input_path) - .expect("failed to clean uppercase dash-prefixed input fixture"); -} - -#[test] -fn run_check_mode_supports_double_dash_for_dash_prefixed_uppercase_input_paths() { - let input_path = std::env::temp_dir().join(format!( - "--daglang_run_check_mode_dash_prefixed_uppercase_input_{}_{}.DAG", - 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::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect("failed to copy makegen fixture for check-mode uppercase dash-prefixed input"); - let output_path = unique_temp_output_file("run_check_mode_dash_prefixed_uppercase_input"); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale output for check-mode uppercase dash-prefixed input"); - } - let stale = "check-mode -- uppercase dash-prefixed stale content"; - std::fs::write(&output_path, stale) - .expect("failed to seed stale output for check-mode uppercase dash-prefixed input"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--check-mode") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run check-mode with uppercase dash-prefixed input"); - - assert!( - output.status.success(), - "check-mode run with uppercase dash-prefixed .DAG input after -- should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode run with uppercase dash-prefixed input should report mode=check-mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode run with uppercase dash-prefixed input should report written=false: {stdout}" - ); - let after = std::fs::read_to_string(&output_path) - .expect("failed to read check-mode uppercase dash-prefixed output"); - assert_eq!( - after, stale, - "check-mode run with uppercase dash-prefixed input should preserve output content" - ); - - std::fs::remove_file(&output_path) - .expect("failed to clean check-mode uppercase dash-prefixed output"); - std::fs::remove_file(&input_path) - .expect("failed to clean check-mode uppercase dash-prefixed input fixture"); -} - -#[test] -fn run_check_mode_supports_equals_output_with_double_dash_for_dash_prefixed_uppercase_input_paths() -{ - let input_path = std::env::temp_dir().join(format!( - "--daglang_run_check_mode_equals_double_dash_dash_prefixed_uppercase_input_{}_{}.DAG", - 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::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect("failed to copy makegen fixture for check-mode equals uppercase dash-prefixed input"); - let output_path = unique_temp_output_file( - "run_check_mode_equals_double_dash_dash_prefixed_uppercase_output", - ); - if output_path.exists() { - std::fs::remove_file(&output_path).expect( - "failed to remove stale output for check-mode equals uppercase dash-prefixed input", - ); - } - let stale = "check-mode equals -- uppercase dash-prefixed stale content"; - std::fs::write(&output_path, stale).expect( - "failed to seed stale output for check-mode equals uppercase dash-prefixed input", - ); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--check-mode") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect( - "failed to run daglang run check-mode with equals output and uppercase dash-prefixed input", - ); - - assert!( - output.status.success(), - "check-mode run with equals output and uppercase dash-prefixed .DAG input after -- should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode run with equals output and uppercase dash-prefixed input should report mode=check-mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode run with equals output and uppercase dash-prefixed input should report written=false: {stdout}" - ); - let after = std::fs::read_to_string(&output_path) - .expect("failed to read check-mode equals uppercase dash-prefixed output"); - assert_eq!( - after, stale, - "check-mode run with equals output and uppercase dash-prefixed input should preserve output content" - ); - - std::fs::remove_file(&output_path) - .expect("failed to clean check-mode equals uppercase dash-prefixed output"); - std::fs::remove_file(&input_path) - .expect("failed to clean check-mode equals uppercase dash-prefixed input fixture"); -} - -#[test] -fn run_check_mode_double_dash_dash_prefixed_uppercase_input_does_not_create_missing_output() { - let input_path = std::env::temp_dir().join(format!( - "--daglang_run_check_mode_dash_prefixed_uppercase_missing_output_input_{}_{}.DAG", - 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::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect("failed to copy makegen fixture for check-mode uppercase dash-prefixed missing-output input"); - let output_path = - unique_temp_output_file("run_check_mode_double_dash_dash_prefixed_uppercase_missing_out"); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale output for check-mode missing-output case"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--check-mode") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run check-mode with double-dash uppercase dash-prefixed input"); - - assert!( - output.status.success(), - "check-mode run with double-dash uppercase dash-prefixed input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode double-dash run should report mode=check-mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode double-dash run should report written=false: {stdout}" - ); - assert!( - !output_path.exists(), - "check-mode double-dash run should not create missing output file at {}", - output_path.display() - ); - - std::fs::remove_file(&input_path) - .expect("failed to clean check-mode uppercase dash-prefixed missing-output input fixture"); -} - -#[test] -fn run_check_mode_split_output_double_dash_dash_prefixed_uppercase_input_does_not_create_missing_output( -) { - let input_path = std::env::temp_dir().join(format!( - "--daglang_run_check_mode_split_output_dash_prefixed_uppercase_missing_output_input_{}_{}.DAG", - 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::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect( - "failed to copy makegen fixture for check-mode split-output uppercase dash-prefixed missing-output input", - ); - let output_path = unique_temp_output_file( - "run_check_mode_split_output_double_dash_dash_prefixed_uppercase_missing_out", - ); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale output for check-mode split-output missing-output case"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--check-mode") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run check-mode split-output with double-dash uppercase dash-prefixed input"); - - assert!( - output.status.success(), - "check-mode split-output run with double-dash uppercase dash-prefixed input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode split-output double-dash run should report mode=check-mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode split-output double-dash run should report written=false: {stdout}" - ); - assert!( - !output_path.exists(), - "check-mode split-output double-dash run should not create missing output file at {}", - output_path.display() - ); - - std::fs::remove_file(&input_path).expect( - "failed to clean check-mode split-output uppercase dash-prefixed missing-output input fixture", - ); -} - -#[test] -fn run_real_mode_supports_double_dash_for_dash_prefixed_uppercase_input_paths() { - let input_path = std::env::temp_dir().join(format!( - "--daglang_run_real_mode_dash_prefixed_uppercase_input_{}_{}.DAG", - 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::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect("failed to copy makegen fixture for real-mode uppercase dash-prefixed input"); - let output_path = - unique_temp_output_file("run_real_mode_double_dash_dash_prefixed_uppercase_output"); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale output for real-mode uppercase dash-prefixed input"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run real-mode with uppercase dash-prefixed input"); - - assert!( - output.status.success(), - "real-mode run with uppercase dash-prefixed .DAG input after -- should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=real"), - "real-mode run with uppercase dash-prefixed input should report mode=real: {stdout}" - ); - assert!( - stdout.contains("written=true"), - "real-mode run with uppercase dash-prefixed input should report written=true: {stdout}" - ); - assert!( - output_path.exists(), - "real-mode run with uppercase dash-prefixed input should write output file at {}", - output_path.display() - ); - let written = std::fs::read_to_string(&output_path) - .expect("failed to read real-mode uppercase dash-prefixed output"); - assert!( - written.contains(".PHONY"), - "real-mode run with uppercase dash-prefixed input should write makefile content: {written}" - ); - - std::fs::remove_file(&output_path) - .expect("failed to clean real-mode uppercase dash-prefixed output"); - std::fs::remove_file(&input_path) - .expect("failed to clean real-mode uppercase dash-prefixed input fixture"); -} - -#[test] -fn run_dry_run_supports_equals_output_with_double_dash_for_dash_prefixed_uppercase_input_paths() { - let input_path = std::env::temp_dir().join(format!( - "--daglang_run_dry_mode_equals_double_dash_dash_prefixed_uppercase_input_{}_{}.DAG", - 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::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect("failed to copy makegen fixture for dry-run equals uppercase dash-prefixed input"); - let output_path = unique_temp_output_file( - "run_dry_mode_equals_double_dash_dash_prefixed_uppercase_output", - ); - if output_path.exists() { - std::fs::remove_file(&output_path).expect( - "failed to remove stale output for dry-run equals uppercase dash-prefixed input", - ); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--dry-run") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect( - "failed to run daglang run dry-run with equals output and uppercase dash-prefixed input", - ); - - assert!( - output.status.success(), - "dry-run with equals output and uppercase dash-prefixed .DAG input after -- should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "dry-run with equals output and uppercase dash-prefixed input should report mode=dry-run: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "dry-run with equals output and uppercase dash-prefixed input should report written=false: {stdout}" - ); - assert!( - !output_path.exists(), - "dry-run with equals output and uppercase dash-prefixed input should not create output at {}", - output_path.display() - ); - - std::fs::remove_file(&input_path) - .expect("failed to clean dry-run equals uppercase dash-prefixed input fixture"); -} - -#[test] -fn run_real_mode_supports_equals_output_with_double_dash_for_dash_prefixed_uppercase_input_paths() -{ - let input_path = std::env::temp_dir().join(format!( - "--daglang_run_real_mode_equals_double_dash_dash_prefixed_uppercase_input_{}_{}.DAG", - 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::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect("failed to copy makegen fixture for real-mode equals uppercase dash-prefixed input"); - let output_path = unique_temp_output_file( - "run_real_mode_equals_double_dash_dash_prefixed_uppercase_output", - ); - if output_path.exists() { - std::fs::remove_file(&output_path).expect( - "failed to remove stale output for real-mode equals uppercase dash-prefixed input", - ); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect( - "failed to run daglang run real-mode with equals output and uppercase dash-prefixed input", - ); - - assert!( - output.status.success(), - "real-mode run with equals output and uppercase dash-prefixed .DAG input after -- should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=real"), - "real-mode run with equals output and uppercase dash-prefixed input should report mode=real: {stdout}" - ); - assert!( - stdout.contains("written=true"), - "real-mode run with equals output and uppercase dash-prefixed input should report written=true: {stdout}" - ); - assert!( - output_path.exists(), - "real-mode run with equals output and uppercase dash-prefixed input should write output file at {}", - output_path.display() - ); - let written = std::fs::read_to_string(&output_path) - .expect("failed to read real-mode equals uppercase dash-prefixed output"); - assert!( - written.contains(".PHONY"), - "real-mode run with equals output and uppercase dash-prefixed input should write makefile content: {written}" - ); - - std::fs::remove_file(&output_path) - .expect("failed to clean real-mode equals uppercase dash-prefixed output"); - std::fs::remove_file(&input_path) - .expect("failed to clean real-mode equals uppercase dash-prefixed input fixture"); -} - -#[test] -fn run_real_mode_equals_output_with_double_dash_dash_prefixed_uppercase_input_reports_not_written_when_unchanged( -) { - let input_path = std::env::temp_dir().join(format!( - "--daglang_run_real_mode_equals_double_dash_dash_prefixed_uppercase_idempotent_input_{}_{}.DAG", - 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::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect( - "failed to copy makegen fixture for real-mode equals idempotent uppercase dash-prefixed input", - ); - let output_path = unique_temp_output_file( - "run_real_mode_equals_double_dash_dash_prefixed_uppercase_idempotent_output", - ); - if output_path.exists() { - std::fs::remove_file(&output_path).expect( - "failed to remove stale output for real-mode equals idempotent uppercase dash-prefixed input", - ); - } - - let first_output = Command::new(daglang_bin()) - .arg("run") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect( - "failed to run first real-mode equals idempotent run with uppercase dash-prefixed input", - ); - assert!( - first_output.status.success(), - "first real-mode equals run with uppercase dash-prefixed input should succeed: {}", - String::from_utf8_lossy(&first_output.stderr) - ); - let first_stdout = String::from_utf8_lossy(&first_output.stdout); - assert!( - first_stdout.contains("mode=real"), - "first real-mode equals run with uppercase dash-prefixed input should report mode=real: {first_stdout}" - ); - assert!( - first_stdout.contains("written=true"), - "first real-mode equals run with uppercase dash-prefixed input should report written=true: {first_stdout}" - ); - - let second_output = Command::new(daglang_bin()) - .arg("run") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect( - "failed to run second real-mode equals idempotent run with uppercase dash-prefixed input", - ); - assert!( - second_output.status.success(), - "second real-mode equals run with uppercase dash-prefixed input should succeed: {}", - String::from_utf8_lossy(&second_output.stderr) - ); - let second_stdout = String::from_utf8_lossy(&second_output.stdout); - assert!( - second_stdout.contains("mode=real"), - "second real-mode equals run with uppercase dash-prefixed input should report mode=real: {second_stdout}" - ); - assert!( - second_stdout.contains("written=false"), - "second real-mode equals run with uppercase dash-prefixed input should report written=false when unchanged: {second_stdout}" - ); - - std::fs::remove_file(&output_path) - .expect("failed to clean real-mode equals idempotent uppercase dash-prefixed output"); - std::fs::remove_file(&input_path) - .expect("failed to clean real-mode equals idempotent uppercase dash-prefixed input fixture"); -} - -#[test] -fn run_real_mode_double_dash_dash_prefixed_uppercase_input_reports_not_written_when_unchanged() { - let input_path = std::env::temp_dir().join(format!( - "--daglang_run_real_mode_dash_prefixed_uppercase_idempotent_input_{}_{}.DAG", - 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::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect("failed to copy makegen fixture for real-mode idempotent uppercase dash-prefixed input"); - let output_path = unique_temp_output_file( - "run_real_mode_double_dash_dash_prefixed_uppercase_idempotent_output", - ); - if output_path.exists() { - std::fs::remove_file(&output_path).expect( - "failed to remove stale output for real-mode idempotent uppercase dash-prefixed input", - ); - } - - let first_output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run first real-mode idempotent run with uppercase dash-prefixed input"); - assert!( - first_output.status.success(), - "first real-mode run with uppercase dash-prefixed input should succeed: {}", - String::from_utf8_lossy(&first_output.stderr) - ); - let first_stdout = String::from_utf8_lossy(&first_output.stdout); - assert!( - first_stdout.contains("mode=real"), - "first real-mode run with uppercase dash-prefixed input should report mode=real: {first_stdout}" - ); - assert!( - first_stdout.contains("written=true"), - "first real-mode run with uppercase dash-prefixed input should report written=true: {first_stdout}" - ); - - let second_output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect( - "failed to run second real-mode idempotent run with uppercase dash-prefixed input", - ); - assert!( - second_output.status.success(), - "second real-mode run with uppercase dash-prefixed input should succeed: {}", - String::from_utf8_lossy(&second_output.stderr) - ); - let second_stdout = String::from_utf8_lossy(&second_output.stdout); - assert!( - second_stdout.contains("mode=real"), - "second real-mode run with uppercase dash-prefixed input should report mode=real: {second_stdout}" - ); - assert!( - second_stdout.contains("written=false"), - "second real-mode run with uppercase dash-prefixed input should report written=false when unchanged: {second_stdout}" - ); - - std::fs::remove_file(&output_path).expect( - "failed to clean real-mode idempotent uppercase dash-prefixed output file", - ); - std::fs::remove_file(&input_path) - .expect("failed to clean real-mode idempotent uppercase dash-prefixed input fixture"); -} - -#[test] -fn run_with_double_dash_without_input_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with -- and no input"); - - assert!( - !output.status.success(), - "run with -- and no input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with -- and no input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run requires "), - "run should report missing input after -- explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for -- without input: {stderr}" - ); -} - -#[test] -fn run_with_double_dash_non_dag_input_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--") - .arg("dsl/tools") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with -- and non-dag input"); - - assert!( - !output.status.success(), - "run with -- and non-dag input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with -- and non-dag input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run requires a .dag input file"), - "run should report non-dag input after -- explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for -- non-dag input: {stderr}" - ); -} - -#[test] -fn run_with_double_dash_flag_like_token_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--") - .arg("--dry-run") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with -- and flag-like positional token"); - - assert!( - !output.status.success(), - "run with -- and flag-like positional token should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with -- and flag-like positional token should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run requires a .dag input file"), - "run should treat flag-like token after -- as positional non-dag input: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for -- flag-like positional token: {stderr}" - ); -} - -#[test] -fn run_with_double_dash_output_equals_like_token_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--") - .arg("--output=generated.mk") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with -- and output-like positional token"); - - assert!( - !output.status.success(), - "run with -- and output-like positional token should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with -- and output-like positional token should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run requires a .dag input file"), - "run should treat output-like token after -- as positional non-dag input: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for -- output-like positional token: {stderr}" - ); -} - -#[test] -fn run_with_double_dash_multiple_inputs_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--") - .arg("dsl/tools/makegen.dag") - .arg("dsl/tools/other.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with multiple inputs after --"); - - assert!( - !output.status.success(), - "run with multiple inputs after -- should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with multiple inputs after -- should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run takes exactly one input"), - "run should report single-input constraint after --: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for multiple inputs after --: {stderr}" - ); -} - -#[test] -fn run_with_double_dash_file_then_flag_like_token_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--") - .arg("dsl/tools/makegen.dag") - .arg("--dry-run") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with flag-like second positional token after --"); - - assert!( - !output.status.success(), - "run with -- and flag-like second positional token should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with -- and flag-like second positional token should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run takes exactly one input"), - "run should report single-input constraint for flag-like second positional token: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for flag-like second positional token after --: {stderr}" - ); -} - -#[test] -fn run_with_double_dash_file_then_output_equals_token_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--") - .arg("dsl/tools/makegen.dag") - .arg("--output=alt.mk") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with output-like second positional token after --"); - - assert!( - !output.status.success(), - "run with -- and output-like second positional token should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with -- and output-like second positional token should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run takes exactly one input"), - "run should report single-input constraint for output-like second positional token: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for output-like second positional token after --: {stderr}" - ); -} - -#[test] -fn run_with_double_dash_output_equals_token_then_file_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--") - .arg("--output=alt.mk") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with output-like first positional token after --"); - - assert!( - !output.status.success(), - "run with -- and output-like first positional token should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with -- and output-like first positional token should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run takes exactly one input"), - "run should report single-input constraint when output-like token appears first after --: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for output-like first positional token after --: {stderr}" - ); -} - -#[test] -fn run_with_conflicting_dry_run_and_check_mode_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--dry-run") - .arg("--check-mode") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with conflicting mode flags"); - - assert!( - !output.status.success(), - "run with conflicting dry-run/check-mode flags should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with conflicting mode flags should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("--dry-run and --check-mode cannot be used together"), - "run should report conflicting mode flags explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for conflicting mode flags: {stderr}" - ); -} - -#[test] -fn run_with_conflicting_dry_run_and_check_mode_after_input_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--dry-run") - .arg("--check-mode") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with conflicting mode flags after input"); - - assert!( - !output.status.success(), - "run with conflicting dry-run/check-mode flags after input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with conflicting mode flags after input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("--dry-run and --check-mode cannot be used together"), - "run should report conflicting mode flags after input explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for conflicting mode flags after input: {stderr}" - ); -} - -#[test] -fn run_with_conflicting_dry_run_and_check_mode_with_double_dash_input_exits_nonzero_with_usage_message( -) { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--dry-run") - .arg("--check-mode") - .arg("--") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with conflicting mode flags and -- input"); - - assert!( - !output.status.success(), - "run with conflicting dry-run/check-mode flags and -- input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with conflicting mode flags and -- input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("--dry-run and --check-mode cannot be used together"), - "run should report conflicting mode flags with -- input explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for conflicting mode flags with -- input: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_dry_run_flags_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--dry-run") - .arg("--dry-run") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate dry-run flags"); - - assert!( - !output.status.success(), - "run with duplicate dry-run flags should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate dry-run flags should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts --dry-run at most once"), - "run should report duplicate dry-run flag explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate dry-run flags: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_dry_run_flags_after_input_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--dry-run") - .arg("--dry-run") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate dry-run flags after input"); - - assert!( - !output.status.success(), - "run with duplicate dry-run flags after input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate dry-run flags after input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts --dry-run at most once"), - "run should report duplicate dry-run flag after input explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate dry-run flags after input: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_check_mode_flags_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--check-mode") - .arg("--check-mode") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate check-mode flags"); - - assert!( - !output.status.success(), - "run with duplicate check-mode flags should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate check-mode flags should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts --check-mode at most once"), - "run should report duplicate check-mode flag explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate check-mode flags: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_check_mode_flags_after_input_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--check-mode") - .arg("--check-mode") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate check-mode flags after input"); - - assert!( - !output.status.success(), - "run with duplicate check-mode flags after input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate check-mode flags after input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts --check-mode at most once"), - "run should report duplicate check-mode flag after input explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate check-mode flags after input: {stderr}" - ); -} - -#[test] -fn run_with_non_dag_input_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with non-dag input"); - - assert!( - !output.status.success(), - "run with non-dag input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with non-dag input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run requires a .dag input file"), - "run should report non-dag input explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for non-dag input: {stderr}" - ); -} - -#[test] -fn run_with_uppercase_dag_input_succeeds() { - let input_path = unique_temp_file("run_uppercase_input").with_extension("DAG"); - std::fs::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect("failed to copy makegen fixture to uppercase extension path"); - let output_path = unique_temp_output_file("run_uppercase_input_output"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale uppercase output file"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg(input_path.to_string_lossy().as_ref()) - .arg("--dry-run") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with uppercase .DAG input"); - - assert!( - output.status.success(), - "run should accept uppercase .DAG input paths: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "uppercase .DAG run should execute in dry-run mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "uppercase .DAG dry-run should report no writes: {stdout}" - ); - assert!( - !output_path.exists(), - "dry-run uppercase input should not create output file" - ); - - std::fs::remove_file(&input_path).expect("failed to clean uppercase input fixture"); -} - -#[test] -fn run_with_double_dash_uppercase_dag_input_succeeds() { - let input_path = unique_temp_file("run_double_dash_uppercase_input").with_extension("DAG"); - std::fs::copy( - workspace_root().join("dsl/tools/makegen.dag"), - &input_path, - ) - .expect("failed to copy makegen fixture to uppercase extension path"); - let output_path = unique_temp_output_file("run_double_dash_uppercase_input_output"); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale uppercase output file for -- test"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--dry-run") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("--") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with -- and uppercase .DAG input"); - - assert!( - output.status.success(), - "run should accept uppercase .DAG input paths after --: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "uppercase .DAG run after -- should execute in dry-run mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "uppercase .DAG dry-run after -- should report no writes: {stdout}" - ); - assert!( - !output_path.exists(), - "dry-run uppercase input after -- should not create output file" - ); - - std::fs::remove_file(&input_path).expect("failed to clean uppercase input fixture"); -} - -#[test] -fn run_with_directory_named_dag_extension_exits_nonzero_with_usage_message() { - let input_dir = unique_temp_dir("run_directory_named_dag").with_extension("dag"); - std::fs::create_dir_all(&input_dir).expect("failed to create directory .dag fixture"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg(input_dir.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with .dag directory input"); - - assert!( - !output.status.success(), - "run with .dag directory input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with .dag directory input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run requires a .dag input file, not a directory"), - "run should report directory input validation explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for directory .dag input: {stderr}" - ); - - std::fs::remove_dir_all(&input_dir).expect("failed to clean .dag directory fixture"); -} - -#[test] -fn run_with_double_dash_directory_named_dag_extension_exits_nonzero_with_usage_message() { - let input_dir = unique_temp_dir("run_double_dash_directory_named_dag").with_extension("dag"); - std::fs::create_dir_all(&input_dir).expect("failed to create directory .dag fixture"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--") - .arg(input_dir.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with -- and .dag directory input"); - - assert!( - !output.status.success(), - "run with -- and .dag directory input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with -- and .dag directory input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run requires a .dag input file, not a directory"), - "run should report directory input validation explicitly after --: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for -- and directory .dag input: {stderr}" - ); - - std::fs::remove_dir_all(&input_dir).expect("failed to clean .dag directory fixture"); -} - -#[test] -fn run_with_missing_dag_file_exits_nonzero_without_usage_banner() { - let missing_input = std::env::temp_dir().join(format!( - "daglang_run_missing_input_{}_{}.dag", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - if missing_input.exists() { - std::fs::remove_file(&missing_input).expect("failed to remove stale missing-input fixture"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg(missing_input.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with missing input file"); - - assert!( - !output.status.success(), - "run with missing input file should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with missing input file should use error exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("failed to read"), - "run should report filesystem read error for missing input: {stderr}" - ); - assert!( - stderr.contains(missing_input.to_string_lossy().as_ref()), - "run should include missing input path in error output: {stderr}" - ); - assert!( - !stderr.contains("Usage: daglang run"), - "missing input is a runtime compile error, not a usage parse error: {stderr}" - ); -} - -#[test] -fn run_with_double_dash_missing_dag_file_exits_nonzero_without_usage_banner() { - let missing_input = std::env::temp_dir().join(format!( - "daglang_run_missing_input_after_double_dash_{}_{}.dag", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - if missing_input.exists() { - std::fs::remove_file(&missing_input) - .expect("failed to remove stale missing-input fixture"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--") - .arg(missing_input.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with missing input file after --"); - - assert!( - !output.status.success(), - "run with missing input file after -- should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with missing input file after -- should use error exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("failed to read"), - "run should report filesystem read error for missing input after --: {stderr}" - ); - assert!( - stderr.contains(missing_input.to_string_lossy().as_ref()), - "run should include missing input path after -- in error output: {stderr}" - ); - assert!( - !stderr.contains("Usage: daglang run"), - "missing input after -- is a runtime compile error, not a usage parse error: {stderr}" - ); -} - -#[test] -fn run_with_missing_output_value_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg("--dry-run") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with missing output path value"); - - assert!( - !output.status.success(), - "run with missing --output value should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with missing --output value should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("--output requires a non-empty path"), - "run should report missing output value explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for missing output value: {stderr}" - ); -} - -#[test] -fn run_with_empty_equals_output_value_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output=") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with empty --output= value"); - - assert!( - !output.status.success(), - "run with empty --output= value should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with empty --output= value should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("--output requires a non-empty path"), - "run should report empty output value explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for empty output value: {stderr}" - ); -} - -#[test] -fn run_with_split_output_separator_missing_path_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg("--") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with split output separator and missing path"); - - assert!( - !output.status.success(), - "run with split output separator and missing path should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with split output separator and missing path should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("--output requires a non-empty path"), - "run should report missing escaped output path explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for split output separator missing path: {stderr}" - ); -} - -#[test] -fn run_dry_run_supports_split_output_dash_prefixed_path_via_separator() { - let execution_dir = unique_temp_dir("run_split_output_dash_prefixed_separator"); - std::fs::create_dir_all(&execution_dir) - .expect("failed to create execution directory for split output separator test"); - let output_basename = format!( - "--daglang_run_split_output_separator_output_{}_{}.mk", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - ); - let output_path = execution_dir.join(&output_basename); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale split output separator output file"); - } - let input_path = workspace_root().join("dsl/tools/makegen.dag"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--dry-run") - .arg("--output") - .arg("--") - .arg(&output_basename) - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(&execution_dir) - .output() - .expect("failed to run daglang run with split output separator and dash-prefixed output path"); - - assert!( - output.status.success(), - "dry-run with split output separator and dash-prefixed output path should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "dry-run split output separator run should report mode=dry-run: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "dry-run split output separator run should report written=false: {stdout}" - ); - assert!( - !output_path.exists(), - "dry-run split output separator run should not create output file at {}", - output_path.display() - ); - - std::fs::remove_dir_all(&execution_dir) - .expect("failed to clean execution directory for split output separator test"); -} - -#[test] -fn run_check_mode_supports_split_output_dash_prefixed_path_via_separator() { - let execution_dir = unique_temp_dir("run_check_mode_split_output_dash_prefixed_separator"); - std::fs::create_dir_all(&execution_dir) - .expect("failed to create execution directory for check-mode split output separator test"); - let output_basename = format!( - "--daglang_run_check_mode_split_output_separator_output_{}_{}.mk", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - ); - let output_path = execution_dir.join(&output_basename); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale check-mode split output separator output file"); - } - let stale = "check-mode split output separator stale content"; - std::fs::write(&output_path, stale) - .expect("failed to seed check-mode split output separator stale output file"); - let input_path = workspace_root().join("dsl/tools/makegen.dag"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--check-mode") - .arg("--output") - .arg("--") - .arg(&output_basename) - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(&execution_dir) - .output() - .expect( - "failed to run daglang run check-mode with split output separator and dash-prefixed output path", - ); - - assert!( - output.status.success(), - "check-mode with split output separator and dash-prefixed output path should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode split output separator run should report mode=check-mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode split output separator run should report written=false: {stdout}" - ); - let after = std::fs::read_to_string(&output_path) - .expect("failed to read check-mode split output separator output file"); - assert_eq!( - after, stale, - "check-mode split output separator run should preserve existing output content" - ); - - std::fs::remove_dir_all(&execution_dir) - .expect("failed to clean check-mode split output separator execution directory"); -} - -#[test] -fn run_check_mode_supports_split_output_separator_with_mode_flag_after_escaped_output_path() { - let execution_dir = unique_temp_dir("run_check_mode_split_output_separator_mode_after_path"); - std::fs::create_dir_all(&execution_dir).expect( - "failed to create execution directory for check-mode split output separator mode-order test", - ); - let output_basename = format!( - "--daglang_run_check_mode_split_output_separator_mode_after_path_output_{}_{}.mk", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - ); - let output_path = execution_dir.join(&output_basename); - if output_path.exists() { - std::fs::remove_file(&output_path).expect( - "failed to remove stale check-mode split output separator mode-order output file", - ); - } - let stale = "check-mode split output separator mode-order stale content"; - std::fs::write(&output_path, stale).expect( - "failed to seed check-mode split output separator mode-order stale output file", - ); - let input_path = workspace_root().join("dsl/tools/makegen.dag"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg("--") - .arg(&output_basename) - .arg("--check-mode") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(&execution_dir) - .output() - .expect( - "failed to run daglang run check-mode with split output separator and mode flag after escaped output path", - ); - - assert!( - output.status.success(), - "check-mode split output separator run with mode flag after escaped output path should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode split output separator run with mode flag after escaped output path should report mode=check-mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode split output separator run with mode flag after escaped output path should report written=false: {stdout}" - ); - let after = std::fs::read_to_string(&output_path).expect( - "failed to read check-mode split output separator mode-order output file after run", - ); - assert_eq!( - after, stale, - "check-mode split output separator run with mode flag after escaped output path should preserve existing output content" - ); - - std::fs::remove_dir_all(&execution_dir).expect( - "failed to clean check-mode split output separator mode-order execution directory", - ); -} - -#[test] -fn run_dry_run_supports_split_output_separator_with_mode_flag_after_escaped_output_path() { - let execution_dir = unique_temp_dir("run_dry_run_split_output_separator_mode_after_path"); - std::fs::create_dir_all(&execution_dir).expect( - "failed to create execution directory for dry-run split output separator mode-order test", - ); - let output_basename = format!( - "--daglang_run_dry_run_split_output_separator_mode_after_path_output_{}_{}.mk", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - ); - let output_path = execution_dir.join(&output_basename); - if output_path.exists() { - std::fs::remove_file(&output_path).expect( - "failed to remove stale dry-run split output separator mode-order output file", - ); - } - let input_path = workspace_root().join("dsl/tools/makegen.dag"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg("--") - .arg(&output_basename) - .arg("--dry-run") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(&execution_dir) - .output() - .expect( - "failed to run daglang run dry-run with split output separator and mode flag after escaped output path", - ); - - assert!( - output.status.success(), - "dry-run split output separator run with mode flag after escaped output path should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "dry-run split output separator run with mode flag after escaped output path should report mode=dry-run: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "dry-run split output separator run with mode flag after escaped output path should report written=false: {stdout}" - ); - assert!( - !output_path.exists(), - "dry-run split output separator run with mode flag after escaped output path should not create output file at {}", - output_path.display() - ); - - std::fs::remove_dir_all(&execution_dir).expect( - "failed to clean dry-run split output separator mode-order execution directory", - ); -} - -#[test] -fn run_with_split_output_separator_conflicting_modes_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--dry-run") - .arg("--output") - .arg("--") - .arg("--split-output-conflict.mk") - .arg("--check-mode") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with conflicting modes across split-output separator segments"); - - assert!( - !output.status.success(), - "run with conflicting modes across split-output separator segments should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with conflicting modes across split-output separator segments should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("--dry-run and --check-mode cannot be used together"), - "run should report conflicting mode flags across split-output separator segments explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for conflicting split-output separator mode flags: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_output_after_split_output_separator_escape_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg("--") - .arg("--split-output-first.mk") - .arg("--output") - .arg("out/second.mk") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate output flag after split-output separator escape"); - - assert!( - !output.status.success(), - "run with duplicate output flag after split-output separator escape should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate output flag after split-output separator escape should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts at most one --output path"), - "run should report duplicate output flag after split-output separator escape explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate output after split-output separator escape: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_equals_output_after_split_output_separator_escape_exits_nonzero_with_usage_message( -) { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg("--") - .arg("--split-output-first.mk") - .arg("--output=out/second.mk") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate equals output flag after split-output separator escape"); - - assert!( - !output.status.success(), - "run with duplicate equals output flag after split-output separator escape should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate equals output flag after split-output separator escape should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts at most one --output path"), - "run should report duplicate equals output flag after split-output separator escape explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate equals output after split-output separator escape: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_dry_run_flags_across_split_output_separator_segments_exits_nonzero_with_usage_message( -) { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg("--") - .arg("--split-output-first.mk") - .arg("--dry-run") - .arg("--dry-run") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate dry-run flags across split-output separator segments"); - - assert!( - !output.status.success(), - "run with duplicate dry-run flags across split-output separator segments should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate dry-run flags across split-output separator segments should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts --dry-run at most once"), - "run should report duplicate dry-run flags across split-output separator segments explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate dry-run flags across split-output separator segments: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_check_mode_flags_across_split_output_separator_segments_exits_nonzero_with_usage_message( -) { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg("--") - .arg("--split-output-first.mk") - .arg("--check-mode") - .arg("--check-mode") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate check-mode flags across split-output separator segments"); - - assert!( - !output.status.success(), - "run with duplicate check-mode flags across split-output separator segments should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate check-mode flags across split-output separator segments should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts --check-mode at most once"), - "run should report duplicate check-mode flags across split-output separator segments explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate check-mode flags across split-output separator segments: {stderr}" - ); -} - -#[test] -fn run_dry_run_supports_split_output_separator_with_flag_like_output_value() { - let execution_dir = unique_temp_dir("run_dry_run_split_output_separator_flag_like_output"); - std::fs::create_dir_all(&execution_dir).expect( - "failed to create execution directory for dry-run split output separator flag-like output test", - ); - let output_basename = "--check-mode"; - let output_path = execution_dir.join(output_basename); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale dry-run split output separator flag-like output file"); - } - let input_path = workspace_root().join("dsl/tools/makegen.dag"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg("--") - .arg(output_basename) - .arg("--dry-run") - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(&execution_dir) - .output() - .expect( - "failed to run daglang run dry-run with split output separator and flag-like output value", - ); - - assert!( - output.status.success(), - "dry-run split output separator run with flag-like output value should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "dry-run split output separator run with flag-like output value should report mode=dry-run: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "dry-run split output separator run with flag-like output value should report written=false: {stdout}" - ); - assert!( - !output_path.exists(), - "dry-run split output separator run with flag-like output value should not create output file at {}", - output_path.display() - ); - - std::fs::remove_dir_all(&execution_dir).expect( - "failed to clean dry-run split output separator flag-like output execution directory", - ); -} - -#[test] -fn run_real_mode_supports_split_output_dash_prefixed_path_via_separator_and_reports_idempotence() { - let execution_dir = unique_temp_dir("run_real_mode_split_output_dash_prefixed_separator"); - std::fs::create_dir_all(&execution_dir) - .expect("failed to create execution directory for real-mode split output separator test"); - let output_basename = format!( - "--daglang_run_real_mode_split_output_separator_output_{}_{}.mk", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - ); - let output_path = execution_dir.join(&output_basename); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale real-mode split output separator output file"); - } - let input_path = workspace_root().join("dsl/tools/makegen.dag"); - - let first_output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg("--") - .arg(&output_basename) - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(&execution_dir) - .output() - .expect( - "failed to run first daglang run real-mode with split output separator and dash-prefixed output path", - ); - assert!( - first_output.status.success(), - "first real-mode split output separator run should succeed: {}", - String::from_utf8_lossy(&first_output.stderr) - ); - let first_stdout = String::from_utf8_lossy(&first_output.stdout); - assert!( - first_stdout.contains("mode=real"), - "first real-mode split output separator run should report mode=real: {first_stdout}" - ); - assert!( - first_stdout.contains("written=true"), - "first real-mode split output separator run should report written=true: {first_stdout}" - ); - let written = std::fs::read_to_string(&output_path) - .expect("failed to read first real-mode split output separator output file"); - assert!( - written.contains(".PHONY"), - "real-mode split output separator run should write generated makefile content: {written}" - ); - - let second_output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg("--") - .arg(&output_basename) - .arg(input_path.to_string_lossy().as_ref()) - .current_dir(&execution_dir) - .output() - .expect( - "failed to run second daglang run real-mode with split output separator and dash-prefixed output path", - ); - assert!( - second_output.status.success(), - "second real-mode split output separator run should succeed: {}", - String::from_utf8_lossy(&second_output.stderr) - ); - let second_stdout = String::from_utf8_lossy(&second_output.stdout); - assert!( - second_stdout.contains("mode=real"), - "second real-mode split output separator run should report mode=real: {second_stdout}" - ); - assert!( - second_stdout.contains("written=false"), - "second real-mode split output separator run should report written=false when output is unchanged: {second_stdout}" - ); - - std::fs::remove_dir_all(&execution_dir) - .expect("failed to clean real-mode split output separator execution directory"); -} - -#[test] -fn run_with_duplicate_split_output_flags_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg("out/first.mk") - .arg("--output") - .arg("out/second.mk") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate split output flags"); - - assert!( - !output.status.success(), - "run with duplicate split --output flags should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate split --output flags should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts at most one --output path"), - "run should report duplicate output flags explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate output flags: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_split_output_flags_after_input_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output") - .arg("out/first.mk") - .arg("--output") - .arg("out/second.mk") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate split output flags after input"); - - assert!( - !output.status.success(), - "run with duplicate split --output flags after input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate split --output flags after input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts at most one --output path"), - "run should report duplicate output flags after input explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate output flags after input: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_mixed_output_flag_syntax_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output=out/first.mk") - .arg("--output") - .arg("out/second.mk") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate mixed output flag syntax"); - - assert!( - !output.status.success(), - "run with duplicate mixed output flag syntax should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate mixed output flag syntax should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts at most one --output path"), - "run should report duplicate mixed output flags explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate mixed output flags: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_equals_output_flags_after_input_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output=out/first.mk") - .arg("--output=out/second.mk") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate equals output flags after input"); - - assert!( - !output.status.success(), - "run with duplicate equals output flags after input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate equals output flags after input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts at most one --output path"), - "run should report duplicate equals output flags after input explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate equals output flags after input: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_mixed_output_flags_after_input_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output=out/first.mk") - .arg("--output") - .arg("out/second.mk") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate mixed output flags after input"); - - assert!( - !output.status.success(), - "run with duplicate mixed output flags after input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate mixed output flags after input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts at most one --output path"), - "run should report duplicate mixed output flags after input explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate mixed output flags after input: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_split_output_separator_after_equals_output_exits_nonzero_with_usage_message() -{ - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output=out/first.mk") - .arg("--output") - .arg("--") - .arg("--dash-prefixed-second.mk") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate split output separator after equals output"); - - assert!( - !output.status.success(), - "run with duplicate split output separator after equals output should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate split output separator after equals output should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts at most one --output path"), - "run should report duplicate split output separator after equals output explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate split output separator after equals output: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_split_output_separator_after_equals_output_after_input_exits_nonzero_with_usage_message( -) { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output=out/first.mk") - .arg("--output") - .arg("--") - .arg("--dash-prefixed-second.mk") - .current_dir(workspace_root()) - .output() - .expect( - "failed to run daglang run with duplicate split output separator after equals output after input", - ); - - assert!( - !output.status.success(), - "run with duplicate split output separator after equals output after input should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate split output separator after equals output after input should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts at most one --output path"), - "run should report duplicate split output separator after equals output after input explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate split output separator after equals output after input: {stderr}" - ); -} - -#[test] -fn run_with_duplicate_equals_output_flags_exits_nonzero_with_usage_message() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output=out/first.mk") - .arg("--output=out/second.mk") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with duplicate equals output flags"); - - assert!( - !output.status.success(), - "run with duplicate equals output flags should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with duplicate equals output flags should use usage exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("run accepts at most one --output path"), - "run should report duplicate equals output flags explicitly: {stderr}" - ); - assert!( - stderr.contains("Usage: daglang run [--output ] [--dry-run|--check-mode]"), - "run should include usage for duplicate equals output flags: {stderr}" - ); -} - -#[test] -fn run_with_unwritable_output_path_exits_nonzero_with_write_error() { - let blocker_path = std::env::temp_dir().join(format!( - "daglang_run_write_blocker_{}_{}", - 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::write(&blocker_path, "block output parent") - .expect("failed to create write blocker file"); - let blocked_output_path = blocker_path.join("Makefile"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output") - .arg(blocked_output_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with blocked output path"); - - assert!( - !output.status.success(), - "run with blocked output path should fail with non-zero status" - ); - assert_eq!( - output.status.code(), - Some(1), - "run with blocked output path should use failure exit code 1" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("failed to write"), - "run should report write failure explicitly: {stderr}" - ); - assert!( - stderr.contains(blocked_output_path.to_string_lossy().as_ref()), - "run should include blocked output path in error: {stderr}" - ); - assert!( - !blocked_output_path.exists(), - "run should not create nested path under write blocker file" - ); - - std::fs::remove_file(&blocker_path).expect("failed to remove write blocker file"); -} - -#[test] -fn run_check_mode_with_unwritable_output_path_reports_not_written() { - let blocker_path = std::env::temp_dir().join(format!( - "daglang_run_check_mode_blocker_{}_{}", - 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::write(&blocker_path, "block output parent") - .expect("failed to create check-mode blocker file"); - let blocked_output_path = blocker_path.join("Makefile"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output") - .arg(blocked_output_path.to_string_lossy().as_ref()) - .arg("--check-mode") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run in check-mode with blocked output path"); - - assert!( - output.status.success(), - "check-mode run should succeed even when output path is unwritable: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode run should report check-mode summary: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode run should report no writes on blocked output path: {stdout}" - ); - assert!( - !blocked_output_path.exists(), - "check-mode run should not create output under blocked path" - ); - - std::fs::remove_file(&blocker_path).expect("failed to remove check-mode blocker file"); -} - -#[test] -fn run_check_mode_with_directory_output_path_reports_not_written() { - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output") - .arg(std::env::temp_dir().to_string_lossy().as_ref()) - .arg("--check-mode") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run in check-mode with directory output path"); - - assert!( - output.status.success(), - "check-mode run should tolerate directory output paths without snapshot errors: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode directory run should report check-mode summary: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode directory run should report no writes: {stdout}" - ); -} - -#[test] -fn run_command_writes_makefile_in_real_mode() { - let output_path = unique_temp_output_file("run_real_mode"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale output before run"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run in real mode"); - - assert!( - output.status.success(), - "run in real mode should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("Run completed:"), - "run should report completion details: {stdout}" - ); - assert!( - stdout.contains("mode=real"), - "run should report real mode in summary: {stdout}" - ); - assert!( - stdout.contains("written=true"), - "run should report written=true in real mode summary: {stdout}" - ); - assert!( - output_path.exists(), - "real run should write output file at {}", - output_path.display() - ); - let written = std::fs::read_to_string(&output_path).expect("failed to read written makefile"); - assert!( - written.contains(".PHONY"), - "written makefile should contain expected phony target: {written}" - ); - std::fs::remove_file(&output_path).expect("failed to clean up real mode output file"); -} - -#[test] -fn run_command_real_mode_reports_not_written_when_output_is_unchanged() { - let output_path = unique_temp_output_file("run_real_mode_unchanged"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale output before run"); - } - - let first_output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run first daglang run in real mode"); - assert!( - first_output.status.success(), - "first real run should succeed: {}", - String::from_utf8_lossy(&first_output.stderr) - ); - let first_stdout = String::from_utf8_lossy(&first_output.stdout); - assert!( - first_stdout.contains("written=true"), - "first real run should report written=true: {first_stdout}" - ); - - let second_output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run second daglang run in real mode"); - assert!( - second_output.status.success(), - "second real run should succeed: {}", - String::from_utf8_lossy(&second_output.stderr) - ); - let second_stdout = String::from_utf8_lossy(&second_output.stdout); - assert!( - second_stdout.contains("mode=real"), - "second real run should report real mode: {second_stdout}" - ); - assert!( - second_stdout.contains("written=false"), - "second real run should report written=false when output is unchanged: {second_stdout}" - ); - std::fs::remove_file(&output_path).expect("failed to clean up unchanged real mode output file"); -} - -#[test] -fn run_command_real_mode_equals_output_reports_not_written_when_output_is_unchanged() { - let output_path = unique_temp_output_file("run_real_mode_equals_unchanged"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale output before run"); - } - - let first_output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg(format!("--output={}", output_path.to_string_lossy())) - .current_dir(workspace_root()) - .output() - .expect("failed to run first daglang run in real mode with --output="); - assert!( - first_output.status.success(), - "first real run with --output= should succeed: {}", - String::from_utf8_lossy(&first_output.stderr) - ); - let first_stdout = String::from_utf8_lossy(&first_output.stdout); - assert!( - first_stdout.contains("written=true"), - "first real run with --output= should report written=true: {first_stdout}" - ); - - let second_output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg(format!("--output={}", output_path.to_string_lossy())) - .current_dir(workspace_root()) - .output() - .expect("failed to run second daglang run in real mode with --output="); - assert!( - second_output.status.success(), - "second real run with --output= should succeed: {}", - String::from_utf8_lossy(&second_output.stderr) - ); - let second_stdout = String::from_utf8_lossy(&second_output.stdout); - assert!( - second_stdout.contains("mode=real"), - "second real run with --output= should report real mode: {second_stdout}" - ); - assert!( - second_stdout.contains("written=false"), - "second real run with --output= should report written=false when output is unchanged: {second_stdout}" - ); - std::fs::remove_file(&output_path) - .expect("failed to clean up unchanged real mode equals-output file"); -} - -#[test] -fn run_command_dry_run_does_not_write_makefile() { - let output_path = unique_temp_output_file("run_dry_mode"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale dry-run output"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("--dry-run") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run in dry-run mode"); - - assert!( - output.status.success(), - "run in dry-run mode should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "run should report dry-run mode in summary: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "run dry-run summary should report no writes: {stdout}" - ); - assert!( - !output_path.exists(), - "dry-run should not write output file at {}", - output_path.display() - ); -} - -#[test] -fn run_command_dry_run_with_split_output_before_input_does_not_write_makefile() { - let output_path = unique_temp_output_file("run_dry_mode_split_before_input"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale dry-run output"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--dry-run") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run in dry-run mode with split --output before input"); - - assert!( - output.status.success(), - "run in dry-run mode with split --output before input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "run should report dry-run mode for split --output before input: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "run dry-run summary should report no writes for split --output before input: {stdout}" - ); - assert!( - !output_path.exists(), - "dry-run with split --output before input should not write output file at {}", - output_path.display() - ); -} - -#[test] -fn run_command_dry_run_equals_output_before_input_does_not_write_makefile() { - let output_path = unique_temp_output_file("run_dry_mode_equals_before_input"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale dry-run output"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--dry-run") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run in dry-run mode with --output= before input"); - - assert!( - output.status.success(), - "run in dry-run mode with --output= before input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "run should report dry-run mode for --output= before input: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "run dry-run summary should report no writes for --output= before input: {stdout}" - ); - assert!( - !output_path.exists(), - "dry-run with --output= before input should not write output file at {}", - output_path.display() - ); -} - -#[test] -fn run_command_dry_run_accepts_flags_after_input_path() { - let output_path = unique_temp_output_file("run_dry_mode_flags_after_input"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale dry-run output"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--dry-run") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run in dry-run mode with flags after input"); - - assert!( - output.status.success(), - "run in dry-run mode with flags after input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "run should report dry-run mode with flags after input path: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "run dry-run summary should report no writes with flags after input: {stdout}" - ); - assert!( - !output_path.exists(), - "dry-run with flags after input should not write output file at {}", - output_path.display() - ); -} - -#[test] -fn run_command_dry_run_supports_equals_output_flag_after_input_path() { - let output_path = unique_temp_output_file("run_dry_mode_equals_after_input"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale dry-run output"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--dry-run") - .arg(format!("--output={}", output_path.to_string_lossy())) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run in dry-run mode with --output= after input"); - - assert!( - output.status.success(), - "run in dry-run mode with --output= after input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "run should report dry-run mode with --output= after input: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "dry-run with --output= after input should report written=false: {stdout}" - ); - assert!( - !output_path.exists(), - "dry-run with --output= after input should not write output file at {}", - output_path.display() - ); -} - -#[test] -fn run_command_supports_equals_output_flag_in_real_mode() { - let output_path = unique_temp_output_file("run_real_mode_equals_output"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale output before run"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg(format!("--output={}", output_path.to_string_lossy())) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with --output= syntax"); - - assert!( - output.status.success(), - "run with --output= should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=real"), - "run with --output= should report real mode summary: {stdout}" - ); - assert!( - stdout.contains("written=true"), - "run with --output= should report written=true summary: {stdout}" - ); - assert!( - output_path.exists(), - "real run should write output file at {}", - output_path.display() - ); - std::fs::remove_file(&output_path).expect("failed to clean up equals-output file"); -} - -#[test] -fn run_command_real_mode_with_split_output_before_input_writes_makefile() { - let output_path = unique_temp_output_file("run_real_mode_split_before_input"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale split-output file"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with split --output before input"); - - assert!( - output.status.success(), - "run with split --output before input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=real"), - "run with split --output before input should report real mode summary: {stdout}" - ); - assert!( - stdout.contains("written=true"), - "run with split --output before input should report written=true summary: {stdout}" - ); - assert!( - output_path.exists(), - "real run with split --output before input should write output file at {}", - output_path.display() - ); - let written = - std::fs::read_to_string(&output_path).expect("failed to read split-before-input output"); - assert!( - written.contains(".PHONY"), - "split-before-input real-mode output should contain makefile content: {written}" - ); - std::fs::remove_file(&output_path) - .expect("failed to clean up real-mode split-before-input output"); -} - -#[test] -fn run_command_real_mode_equals_output_before_input_writes_makefile() { - let output_path = unique_temp_output_file("run_real_mode_equals_before_input"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale equals-output file"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with --output= before input"); - - assert!( - output.status.success(), - "run with --output= before input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=real"), - "run with --output= before input should report real mode summary: {stdout}" - ); - assert!( - stdout.contains("written=true"), - "run with --output= before input should report written=true summary: {stdout}" - ); - assert!( - output_path.exists(), - "real run with --output= before input should write output file at {}", - output_path.display() - ); - let written = - std::fs::read_to_string(&output_path).expect("failed to read equals-before-input output"); - assert!( - written.contains(".PHONY"), - "equals-before-input real-mode output should contain makefile content: {written}" - ); - std::fs::remove_file(&output_path) - .expect("failed to clean up real-mode equals-before-input output"); -} - -#[test] -fn run_command_supports_equals_output_flag_with_dash_prefixed_path_in_dry_run() { - let output_path = std::env::temp_dir().join(format!( - "--daglang_run_dash_output_{}_{}.mk", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale dash-prefixed output"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("--dry-run") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with dash-prefixed --output="); - - assert!( - output.status.success(), - "run with dash-prefixed --output= should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "run should report dry-run mode with dash-prefixed output path: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "dry-run with dash-prefixed output path should report written=false: {stdout}" - ); - assert!( - !output_path.exists(), - "dry-run should not write dash-prefixed output file at {}", - output_path.display() - ); -} - -#[test] -fn run_command_supports_dash_prefixed_equals_output_before_input_in_dry_run() { - let output_path = std::env::temp_dir().join(format!( - "--daglang_run_dash_output_before_input_{}_{}.mk", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale dash-prefixed output"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--dry-run") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect( - "failed to run daglang run with dash-prefixed --output= before input in dry-run", - ); - - assert!( - output.status.success(), - "run with dash-prefixed --output= before input in dry-run should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=dry-run"), - "run with dash-prefixed --output= before input should report dry-run mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "run with dash-prefixed --output= before input should report written=false in dry-run: {stdout}" - ); - assert!( - !output_path.exists(), - "run with dash-prefixed --output= before input should not write output file at {}", - output_path.display() - ); -} - -#[test] -fn run_command_supports_equals_output_flag_with_dash_prefixed_path_in_real_mode() { - let output_path = std::env::temp_dir().join(format!( - "--daglang_run_dash_output_real_{}_{}.mk", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale real-mode dash-prefixed output"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg(format!("--output={}", output_path.to_string_lossy())) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with real-mode dash-prefixed --output="); - - assert!( - output.status.success(), - "real-mode run with dash-prefixed --output= should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=real"), - "run should report real mode with dash-prefixed output path: {stdout}" - ); - assert!( - stdout.contains("written=true"), - "real-mode run with dash-prefixed output path should report written=true: {stdout}" - ); - assert!( - output_path.exists(), - "real-mode run should write dash-prefixed output file at {}", - output_path.display() - ); - let written = - std::fs::read_to_string(&output_path).expect("failed to read dash-prefixed real output"); - assert!( - written.contains(".PHONY"), - "dash-prefixed real-mode output should contain expected makefile content: {written}" - ); - std::fs::remove_file(&output_path) - .expect("failed to clean up real-mode dash-prefixed output file"); -} - -#[test] -fn run_command_real_mode_dash_prefixed_equals_output_reports_not_written_when_output_is_unchanged() { - let output_path = std::env::temp_dir().join(format!( - "--daglang_run_dash_output_real_unchanged_{}_{}.mk", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - if output_path.exists() { - std::fs::remove_file(&output_path) - .expect("failed to remove stale unchanged real-mode dash-prefixed output"); - } - - let first_output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg(format!("--output={}", output_path.to_string_lossy())) - .current_dir(workspace_root()) - .output() - .expect("failed to run first real-mode dash-prefixed --output= run"); - assert!( - first_output.status.success(), - "first real-mode dash-prefixed --output= run should succeed: {}", - String::from_utf8_lossy(&first_output.stderr) - ); - let first_stdout = String::from_utf8_lossy(&first_output.stdout); - assert!( - first_stdout.contains("written=true"), - "first real-mode dash-prefixed --output= run should report written=true: {first_stdout}" - ); - - let second_output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg(format!("--output={}", output_path.to_string_lossy())) - .current_dir(workspace_root()) - .output() - .expect("failed to run second real-mode dash-prefixed --output= run"); - assert!( - second_output.status.success(), - "second real-mode dash-prefixed --output= run should succeed: {}", - String::from_utf8_lossy(&second_output.stderr) - ); - let second_stdout = String::from_utf8_lossy(&second_output.stdout); - assert!( - second_stdout.contains("mode=real"), - "second real-mode dash-prefixed --output= run should report mode=real: {second_stdout}" - ); - assert!( - second_stdout.contains("written=false"), - "second real-mode dash-prefixed --output= run should report written=false when output is unchanged: {second_stdout}" - ); - std::fs::remove_file(&output_path) - .expect("failed to clean up unchanged real-mode dash-prefixed output"); -} - -#[test] -fn run_command_check_mode_preserves_existing_file_and_reports_not_written() { - let output_path = unique_temp_output_file("run_check_mode"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale check-mode output"); - } - let stale = "stale makefile content"; - std::fs::write(&output_path, stale).expect("failed to seed stale check-mode file"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("--check-mode") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with --check-mode"); - - assert!( - output.status.success(), - "run with --check-mode should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode run should report check-mode mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode run should report written=false when writes are skipped: {stdout}" - ); - let after = std::fs::read_to_string(&output_path).expect("failed to read check-mode file"); - assert_eq!( - after, stale, - "check-mode run should not overwrite existing output content" - ); - std::fs::remove_file(&output_path).expect("failed to clean up check-mode output file"); -} - -#[test] -fn run_command_check_mode_with_split_output_before_input_preserves_existing_file() { - let output_path = unique_temp_output_file("run_check_mode_split_before_input"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale check-mode output"); - } - let stale = "check-mode split-before-input stale content"; - std::fs::write(&output_path, stale).expect("failed to seed stale check-mode file"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--check-mode") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with split output check-mode flags before input"); - - assert!( - output.status.success(), - "run with split output check-mode flags before input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode run with split output flags before input should report mode=check-mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode run with split output flags before input should report written=false: {stdout}" - ); - let after = std::fs::read_to_string(&output_path).expect("failed to read check-mode file"); - assert_eq!( - after, stale, - "check-mode run with split output flags before input should preserve output content" - ); - std::fs::remove_file(&output_path).expect("failed to clean up check-mode output file"); -} - -#[test] -fn run_command_check_mode_with_split_output_before_input_does_not_create_missing_file() { - let output_path = unique_temp_output_file("run_check_mode_split_before_input_missing_output"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale check-mode output"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--check-mode") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with split output check-mode flags before input for missing output"); - - assert!( - output.status.success(), - "run with split output check-mode flags before input and missing output should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode run with split output flags before input and missing output should report mode=check-mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode run with split output flags before input and missing output should report written=false: {stdout}" - ); - assert!( - !output_path.exists(), - "check-mode run with split output flags before input should not create missing output file at {}", - output_path.display() - ); -} - -#[test] -fn run_command_check_mode_accepts_flags_after_input_path() { - let output_path = unique_temp_output_file("run_check_mode_flags_after_input"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale check-mode output"); - } - let stale = "check-mode ordering stale content"; - std::fs::write(&output_path, stale).expect("failed to seed stale check-mode file"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--check-mode") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with check-mode flags after input"); - - assert!( - output.status.success(), - "run with check-mode flags after input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode run with flags after input should report check-mode mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode run with flags after input should report written=false: {stdout}" - ); - let after = std::fs::read_to_string(&output_path).expect("failed to read check-mode file"); - assert_eq!( - after, stale, - "check-mode run with flags after input should preserve output content" - ); - std::fs::remove_file(&output_path).expect("failed to clean up check-mode output file"); -} - -#[test] -fn run_command_check_mode_supports_equals_output_flag_after_input_path() { - let output_path = unique_temp_output_file("run_check_mode_equals_after_input"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale check-mode output"); - } - let stale = "check-mode equals ordering stale content"; - std::fs::write(&output_path, stale).expect("failed to seed stale check-mode file"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--check-mode") - .arg(format!("--output={}", output_path.to_string_lossy())) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with check-mode --output= after input"); - - assert!( - output.status.success(), - "run with check-mode --output= after input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode run with --output= after input should report check-mode mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode run with --output= after input should report written=false: {stdout}" - ); - let after = std::fs::read_to_string(&output_path).expect("failed to read check-mode file"); - assert_eq!( - after, stale, - "check-mode run with --output= after input should preserve output content" - ); - std::fs::remove_file(&output_path).expect("failed to clean up check-mode output file"); -} - -#[test] -fn run_command_check_mode_supports_dash_prefixed_equals_output_path() { - let output_path = std::env::temp_dir().join(format!( - "--daglang_run_check_mode_dash_output_{}_{}.mk", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale check-mode output"); - } - let stale = "check-mode dash-prefixed output stale content"; - std::fs::write(&output_path, stale).expect("failed to seed stale check-mode output file"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--check-mode") - .arg(format!("--output={}", output_path.to_string_lossy())) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with check-mode dash-prefixed --output="); - - assert!( - output.status.success(), - "check-mode run with dash-prefixed --output= should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode run with dash-prefixed output should report check-mode mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode run with dash-prefixed output should report written=false: {stdout}" - ); - let after = std::fs::read_to_string(&output_path) - .expect("failed to read dash-prefixed check-mode output"); - assert_eq!( - after, stale, - "check-mode run should preserve dash-prefixed output content" - ); - std::fs::remove_file(&output_path) - .expect("failed to clean up dash-prefixed check-mode output file"); -} - -#[test] -fn run_command_check_mode_supports_dash_prefixed_equals_output_before_input_path() { - let output_path = std::env::temp_dir().join(format!( - "--daglang_run_check_mode_dash_output_before_input_{}_{}.mk", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale check-mode output"); - } - let stale = "check-mode dash-prefixed before-input stale content"; - std::fs::write(&output_path, stale).expect("failed to seed stale check-mode output file"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--check-mode") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect( - "failed to run daglang run with check-mode dash-prefixed --output= before input", - ); - - assert!( - output.status.success(), - "check-mode run with dash-prefixed --output= before input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode run with dash-prefixed --output= before input should report mode=check-mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode run with dash-prefixed --output= before input should report written=false: {stdout}" - ); - let after = std::fs::read_to_string(&output_path) - .expect("failed to read dash-prefixed check-mode output"); - assert_eq!( - after, stale, - "check-mode run should preserve dash-prefixed output content when flags precede input" - ); - std::fs::remove_file(&output_path) - .expect("failed to clean up dash-prefixed check-mode output file"); -} - -#[test] -fn run_command_check_mode_preserves_existing_binary_file_and_reports_not_written() { - let output_path = unique_temp_output_file("run_check_mode_binary"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale binary check-mode output"); - } - let seeded_bytes = vec![0u8, 159, 255, 10, 13, 42]; - std::fs::write(&output_path, &seeded_bytes) - .expect("failed to seed binary check-mode output file"); - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("--check-mode") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with --check-mode for binary output"); - - assert!( - output.status.success(), - "run with --check-mode should succeed for binary output files: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode run should report check-mode mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode run should report written=false for binary outputs: {stdout}" - ); - let after = std::fs::read(&output_path).expect("failed to read binary check-mode file"); - assert_eq!( - after, seeded_bytes, - "check-mode run should preserve existing binary output content" - ); - std::fs::remove_file(&output_path).expect("failed to clean up binary check-mode output file"); -} - -#[test] -fn run_command_check_mode_does_not_create_missing_output_file() { - let output_path = unique_temp_output_file("run_check_mode_missing"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale check-mode output"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("dsl/tools/makegen.dag") - .arg("--output") - .arg(output_path.to_string_lossy().as_ref()) - .arg("--check-mode") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with --check-mode on missing path"); - - assert!( - output.status.success(), - "run with --check-mode should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("written=false"), - "check-mode run should report written=false for missing outputs: {stdout}" - ); - assert!( - !output_path.exists(), - "check-mode run should not leave a newly-created file at {}", - output_path.display() - ); -} - -#[test] -fn run_command_check_mode_equals_output_before_input_does_not_create_missing_file() { - let output_path = unique_temp_output_file("run_check_mode_equals_before_input_missing"); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale check-mode output"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--check-mode") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with --check-mode --output= before input"); - - assert!( - output.status.success(), - "run with check-mode equals output before input should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode equals-output run before input should report check-mode mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode equals-output run before input should report written=false: {stdout}" - ); - assert!( - !output_path.exists(), - "check-mode equals-output run before input should not create {}", - output_path.display() - ); -} - -#[test] -fn run_command_check_mode_dash_prefixed_equals_output_missing_file_is_not_created() { - let output_path = std::env::temp_dir().join(format!( - "--daglang_run_check_mode_dash_missing_output_{}_{}.mk", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after unix epoch") - .as_nanos() - )); - if output_path.exists() { - std::fs::remove_file(&output_path).expect("failed to remove stale check-mode output"); - } - - let output = Command::new(daglang_bin()) - .arg("run") - .arg("--check-mode") - .arg(format!("--output={}", output_path.to_string_lossy())) - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang run with dash-prefixed check-mode --output="); - - assert!( - output.status.success(), - "check-mode run with missing dash-prefixed output path should succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("mode=check-mode"), - "check-mode run with missing dash-prefixed output path should report mode=check-mode: {stdout}" - ); - assert!( - stdout.contains("written=false"), - "check-mode run with missing dash-prefixed output path should report written=false: {stdout}" - ); - assert!( - !output_path.exists(), - "check-mode run with missing dash-prefixed output path should not create {}", - output_path.display() - ); -} - -#[test] -fn compile_family_commands_execute_real_pipeline_paths() { - for command in ["expand", "manifest", "obligations", "show-triplets", "compile"] { - let output = Command::new(daglang_bin()) - .arg(command) - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .unwrap_or_else(|err| panic!("failed to run {command}: {err}")); - assert!( - output.status.success(), - "{command} should execute successfully for makegen fixture" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !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_emits_compiled_ascii_graph_by_default() { - let output = Command::new(daglang_bin()) - .arg("viz") - .arg("dsl/tools/makegen.dag") - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang viz"); - - assert!( - output.status.success(), - "viz should compile and render ascii output" - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("ASCII DAG: daglang-compiled"), - "viz ascii output should include ascii header: {stdout}" - ); - assert!( - stdout.contains("tools.makegen::render_makefile"), - "viz ascii output should include compiled node ids: {stdout}" + stdout.contains("flowchart TB"), + "viz mermaid output should include flowchart header: {stdout}" ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( diff --git a/core/daglang/daglang-cli/tests/compile_commands.rs b/core/daglang/daglang-cli/tests/compile_commands.rs index 930c44c93ec..0e1a064074b 100644 --- a/core/daglang/daglang-cli/tests/compile_commands.rs +++ b/core/daglang/daglang-cli/tests/compile_commands.rs @@ -2,8 +2,9 @@ #![allow(clippy::disallowed_methods, clippy::disallowed_types)] use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Output}; use std::time::{SystemTime, UNIX_EPOCH}; +use serde_json::Value; fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..") @@ -77,6 +78,264 @@ fn assert_no_stage_failures(stderr: &str) { ); } +fn run_compile_with_optional_trailing_slash(root: &Path, input: &str) -> (Output, Output) { + let plain = Command::new(daglang_bin()) + .arg("compile") + .arg(input) + .current_dir(root) + .output() + .expect("failed to run plain compile invocation"); + let trailing = Command::new(daglang_bin()) + .arg("compile") + .arg(format!("{input}/")) + .current_dir(root) + .output() + .expect("failed to run trailing-slash compile invocation"); + (plain, trailing) +} + +fn run_single_target_command_with_optional_trailing_slash_and_args( + command_name: &str, + root: &Path, + input: &str, + extra_args: &[&str], +) -> (Output, Output) { + let plain = run_single_target_command(command_name, root, input, extra_args) + .expect("failed to run plain single-target command invocation"); + let trailing = run_single_target_command(command_name, root, &format!("{input}/"), extra_args) + .expect("failed to run trailing-slash single-target command invocation"); + (plain, trailing) +} + +fn run_single_target_command( + command_name: &str, + root: &Path, + target: &str, + extra_args: &[&str], +) -> std::io::Result { + Command::new(daglang_bin()) + .arg(command_name) + .arg(target) + .args(extra_args) + .current_dir(root) + .output() +} + +fn assert_single_target_command_outputs_match_for_targets( + command_name: &str, + root: &Path, + canonical_target: &str, + variant_target: &str, + extra_args: &[&str], + variant_label: &str, +) { + let canonical = run_single_target_command(command_name, root, canonical_target, extra_args) + .expect("failed to run canonical single-target command invocation"); + assert!( + canonical.status.success(), + "canonical {command_name} invocation failed: {}", + String::from_utf8_lossy(&canonical.stderr) + ); + + let variant = run_single_target_command(command_name, root, variant_target, extra_args) + .expect("failed to run variant single-target command invocation"); + assert!( + variant.status.success(), + "{variant_label} {command_name} invocation failed: {}", + String::from_utf8_lossy(&variant.stderr) + ); + + assert_eq!( + canonical.stdout, variant.stdout, + "canonical and {variant_label} {command_name} stdout should match" + ); + assert_eq!( + canonical.stderr, variant.stderr, + "canonical and {variant_label} {command_name} stderr should match" + ); +} + +fn assert_single_target_command_failure_outputs_match_for_targets( + command_name: &str, + root: &Path, + canonical_target: &str, + variant_target: &str, + extra_args: &[&str], + variant_label: &str, +) { + let canonical = run_single_target_command(command_name, root, canonical_target, extra_args) + .expect("failed to run canonical single-target command invocation"); + assert!( + !canonical.status.success(), + "canonical {command_name} invocation should fail for invalid target" + ); + + let variant = run_single_target_command(command_name, root, variant_target, extra_args) + .expect("failed to run variant single-target command invocation"); + assert!( + !variant.status.success(), + "{variant_label} {command_name} invocation should fail for invalid target" + ); + + assert_eq!( + canonical.stdout, variant.stdout, + "canonical and {variant_label} {command_name} failing stdout should match" + ); + assert_eq!( + canonical.stderr, variant.stderr, + "canonical and {variant_label} {command_name} failing stderr should match" + ); +} + +fn assert_single_target_command_failure_outputs_match_for_variants( + command_name: &str, + root: &Path, + canonical_target: &str, + variant_targets: &[(&str, &str)], + extra_args: &[&str], +) { + for (variant_target, variant_label) in variant_targets { + assert_single_target_command_failure_outputs_match_for_targets( + command_name, + root, + canonical_target, + variant_target, + extra_args, + variant_label, + ); + } +} + +fn assert_makegen_target_variant_matches_canonical_output( + command_name: &str, + variant_target: &str, + extra_args: &[&str], + variant_label: &str, +) { + let root = workspace_root(); + let canonical_target = makegen_file().to_string_lossy().into_owned(); + assert_single_target_command_outputs_match_for_targets( + command_name, + &root, + &canonical_target, + variant_target, + extra_args, + variant_label, + ); +} + +fn assert_dag_suffixed_directory_is_invalid_single_file_target( + root: &Path, + input: &str, + expected_target: &Path, + nested_diagnostic_snippet: Option<&str>, +) { + let (plain, trailing) = run_compile_with_optional_trailing_slash(root, input); + assert!( + !plain.status.success(), + "{input} compile should fail for .dag-suffixed directory target" + ); + assert!( + !trailing.status.success(), + "{input}/ compile should fail for .dag-suffixed directory target" + ); + + assert_eq!( + plain.stdout, trailing.stdout, + "{input} plain and trailing-slash compile stdout should match" + ); + assert_eq!( + plain.stderr, trailing.stderr, + "{input} plain and trailing-slash compile stderr should match" + ); + + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", expected_target.display())), + "{input} compile should fail with normalized single-file target path: {stderr}" + ); + if let Some(snippet) = nested_diagnostic_snippet { + assert!( + !stderr.contains(snippet), + "{input} should fail before parsing nested files: {stderr}" + ); + } + assert_no_stage_failures(&stderr); +} + +fn assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + command_name: &str, + root: &Path, + input: &str, + expected_target: &Path, + nested_diagnostic_snippet: Option<&str>, +) { + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + command_name, + root, + input, + expected_target, + nested_diagnostic_snippet, + &[], + ); +} + +fn assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + command_name: &str, + root: &Path, + input: &str, + expected_target: &Path, + nested_diagnostic_snippet: Option<&str>, + extra_args: &[&str], +) { + let (plain, trailing) = + run_single_target_command_with_optional_trailing_slash_and_args( + command_name, + root, + input, + extra_args, + ); + assert!( + !plain.status.success(), + "{command_name} {input} should fail for .dag-suffixed directory target" + ); + assert!( + !trailing.status.success(), + "{command_name} {input}/ should fail for .dag-suffixed directory target" + ); + + assert_eq!( + plain.stdout, trailing.stdout, + "{command_name} {input} plain and trailing-slash stdout should match" + ); + assert_eq!( + plain.stderr, trailing.stderr, + "{command_name} {input} plain and trailing-slash stderr should match" + ); + + let stderr = String::from_utf8_lossy(&plain.stderr); + assert!( + stderr.contains(&format!("failed to read {}", expected_target.display())), + "{command_name} should fail with normalized single-file target path: {stderr}" + ); + if let Some(snippet) = nested_diagnostic_snippet { + assert!( + !stderr.contains(snippet), + "{command_name} should fail before parsing nested files: {stderr}" + ); + } + assert_no_stage_failures(&stderr); +} + +fn obligations_block(output: &str) -> String { + output + .lines() + .skip_while(|line| *line != "TestObligations:") + .collect::>() + .join("\n") + + "\n" +} + #[test] fn compile_command_emits_summary_for_single_file() { let output = Command::new(daglang_bin()) @@ -201,6 +460,286 @@ fn compile_command_accepts_symlink_root_directory() { std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); } +#[test] +fn compile_command_directory_named_dag_extension_is_treated_as_invalid_single_file_target() { + let root = unique_temp_dir("compile_directory_named_dag_extension"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + assert_dag_suffixed_directory_is_invalid_single_file_target( + &root, + "bundle.dag", + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_directory_named_dag_extension_with_nested_errors_still_fails_as_single_file_target( +) { + let root = unique_temp_dir("compile_directory_named_dag_extension_errors"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .dag directory root"); + let broken_file = dag_dir.join("broken.dag"); + std::fs::write(&broken_file, "module sample.broken\nfn") + .expect("failed to write malformed source in .dag directory"); + + assert_dag_suffixed_directory_is_invalid_single_file_target( + &root, + "bundle.dag", + &dag_dir, + Some("broken.dag:2:3"), + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_directory_named_uppercase_dag_extension_is_treated_as_invalid_single_file_target( +) { + let root = unique_temp_dir("compile_directory_named_uppercase_dag_extension"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(dag_dir.join("sample")) + .expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + assert_dag_suffixed_directory_is_invalid_single_file_target( + &root, + "bundle.DAG", + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_directory_named_mixed_case_dag_extension_is_treated_as_invalid_single_file_target( +) { + let root = unique_temp_dir("compile_directory_named_mixed_case_dag_extension"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DaG directory"); + + assert_dag_suffixed_directory_is_invalid_single_file_target( + &root, + "bundle.DaG", + &dag_dir, + Some("broken.dag:2:3"), + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_suffix_uppercase_dag_directory_is_treated_as_invalid_single_file_target( +) { + let root = unique_temp_dir("compile_curdir_suffix_uppercase_dag_directory"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(dag_dir.join("sample")) + .expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + assert_dag_suffixed_directory_is_invalid_single_file_target( + &root, + "./bundle.DAG", + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn compile_command_curdir_suffix_mixed_case_dag_directory_is_treated_as_invalid_single_file_target( +) { + let root = unique_temp_dir("compile_curdir_suffix_mixed_case_dag_directory"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DaG directory"); + + assert_dag_suffixed_directory_is_invalid_single_file_target( + &root, + "./bundle.DaG", + &dag_dir, + Some("broken.dag:2:3"), + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn compile_command_symlink_directory_named_dag_extension_is_treated_as_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("compile_symlink_directory_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory root"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + assert_dag_suffixed_directory_is_invalid_single_file_target( + &root, + "link.dag", + &link_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn compile_command_symlink_directory_named_mixed_case_dag_extension_is_treated_as_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("compile_symlink_directory_named_mixed_case_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DaG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create mixed-case .DaG directory symlink"); + + assert_dag_suffixed_directory_is_invalid_single_file_target( + &root, + "link.DaG", + &link_dir, + Some("broken.dag:2:3"), + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn compile_command_symlink_directory_named_uppercase_dag_extension_is_treated_as_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("compile_symlink_directory_named_uppercase_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DAG"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory root"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create uppercase .DAG directory symlink"); + + assert_dag_suffixed_directory_is_invalid_single_file_target( + &root, + "link.DAG", + &link_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn compile_command_curdir_suffix_symlink_named_dag_extension_is_invalid_single_file_target() { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("compile_curdir_suffix_symlink_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory root"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create lowercase .dag directory symlink"); + + assert_dag_suffixed_directory_is_invalid_single_file_target( + &root, + "./link.dag", + &link_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn compile_command_curdir_suffix_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("compile_curdir_suffix_symlink_named_uppercase_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DAG"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory root"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create uppercase .DAG directory symlink"); + + assert_dag_suffixed_directory_is_invalid_single_file_target( + &root, + "./link.DAG", + &link_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn compile_command_curdir_suffix_symlink_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("compile_curdir_suffix_symlink_named_mixed_case_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DaG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create mixed-case .DaG directory symlink"); + + assert_dag_suffixed_directory_is_invalid_single_file_target( + &root, + "./link.DaG", + &link_dir, + Some("broken.dag:2:3"), + ); + + 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() { @@ -10582,586 +11121,5638 @@ fn manifest_command_shows_derived_progress_manifest() { } #[test] -fn manifest_command_supports_json_output_format() { +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("--format") - .arg("json") - .arg(makegen_file()) + .arg(&fixture) .current_dir(workspace_root()) .output() - .expect("failed to run daglang manifest --format json"); + .expect("failed to run daglang manifest on fixture"); assert!( output.status.success(), - "manifest --format json command failed: {}", + "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("\"manifest\"")); - assert!(stdout.contains("\"topology\"")); - assert!(stdout.contains("\"parallel_groups\"")); - assert!(stdout.contains("\"capture_modes\"")); - assert!(stdout.contains("\"resources\"")); - assert!(stdout.contains("\"obligations\"")); + 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_supports_equals_json_output_format() { - let output = Command::new(daglang_bin()) - .arg("manifest") - .arg("--format=json") - .arg(makegen_file()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang manifest --format=json"); - - assert!( - output.status.success(), - "manifest --format=json command failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("\"manifest\"")); - assert!(stdout.contains("\"topology\"")); +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 } + } } - -#[test] -fn manifest_json_output_is_deterministic_for_same_input() { - let run_manifest_json = || { - Command::new(daglang_bin()) - .arg("manifest") - .arg("--format") - .arg("json") - .arg(makegen_file()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang manifest --format json") - }; - - let first = run_manifest_json(); - assert!( - first.status.success(), - "first manifest json run failed: {}", - String::from_utf8_lossy(&first.stderr) - ); - let second = run_manifest_json(); - assert!( - second.status.success(), - "second manifest json run failed: {}", - String::from_utf8_lossy(&second.stderr) - ); - assert_eq!( - first.stdout, second.stdout, - "manifest json output should be byte-stable across runs" - ); +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"); -#[test] -fn show_triplets_command_shows_content_upsert_expansion() { let output = Command::new(daglang_bin()) - .arg("show-triplets") - .arg(makegen_file()) + .arg("manifest") + .arg(&fixture) .current_dir(workspace_root()) .output() - .expect("failed to run daglang show-triplets"); + .expect("failed to run daglang manifest on fixture"); assert!( output.status.success(), - "show-triplets command failed: {}", + "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("TransportTriplets:")); - assert!(stdout.contains("prepare_read_makegen")); - assert!(stdout.contains("execute_read_makegen")); - assert!(stdout.contains("compare_makegen_content")); + assert!(stdout.contains("service_param_source_targets: 0")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); } #[test] -fn show_triplets_command_supports_json_output_format() { +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("show-triplets") - .arg("--format") - .arg("json") - .arg(makegen_file()) + .arg("manifest") + .arg(&fixture) .current_dir(workspace_root()) .output() - .expect("failed to run daglang show-triplets --format json"); + .expect("failed to run daglang manifest on fixture"); assert!( output.status.success(), - "show-triplets --format json command failed: {}", + "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("\"triplets\"")); - assert!(stdout.contains("\"prepare_nodes\"")); - assert!(stdout.contains("\"execute_nodes\"")); - assert!(stdout.contains("\"parse_nodes\"")); + 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 show_triplets_command_supports_equals_json_output_format() { +fn obligations_command_shows_derived_obligation_summary() { let output = Command::new(daglang_bin()) - .arg("show-triplets") - .arg("--format=json") + .arg("obligations") .arg(makegen_file()) .current_dir(workspace_root()) .output() - .expect("failed to run daglang show-triplets --format=json"); + .expect("failed to run daglang obligations"); assert!( output.status.success(), - "show-triplets --format=json command failed: {}", + "obligations command failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("\"triplets\"")); - assert!(stdout.contains("\"prepare_nodes\"")); -} - -#[test] -fn show_triplets_json_output_is_deterministic_for_same_input() { - let run_triplets_json = || { - Command::new(daglang_bin()) - .arg("show-triplets") - .arg("--format") - .arg("json") - .arg(makegen_file()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang show-triplets --format json") - }; - - let first = run_triplets_json(); - assert!( - first.status.success(), - "first show-triplets json run failed: {}", - String::from_utf8_lossy(&first.stderr) - ); - let second = run_triplets_json(); - assert!( - second.status.success(), - "second show-triplets json run failed: {}", - String::from_utf8_lossy(&second.stderr) - ); - assert_eq!( - first.stdout, second.stdout, - "show-triplets json output should be byte-stable across runs" - ); + assert!(stdout.contains("TestObligations:")); + assert!(stdout.contains("transport_execution_targets:")); + assert!(stdout.contains("resource_provide_targets:")); } #[test] -fn obligations_command_shows_four_bucket_summary() { +fn obligations_command_json_format_emits_valid_json_object() { let output = Command::new(daglang_bin()) .arg("obligations") .arg(makegen_file()) + .arg("--format") + .arg("json") .current_dir(workspace_root()) .output() - .expect("failed to run daglang obligations"); + .expect("failed to run daglang obligations --format json"); assert!( output.status.success(), - "obligations command failed: {}", + "obligations --format json 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("TestObligationsSummary:")); - assert!(stdout.contains("dry_run_completion_required:")); - assert!(stdout.contains("transport:")); - assert!(stdout.contains("pure_node_determinism:")); - assert!(stdout.contains("resource_lifecycle:")); + let parsed: Value = serde_json::from_slice(&output.stdout) + .expect("obligations --format json should emit valid JSON"); + assert!(parsed.get("dry_run_completion_required").is_some()); + assert!(parsed.get("service_transport_prepare_targets").is_some()); } #[test] -fn obligations_command_reports_non_zero_triplet_counts_for_makegen() { - let output = Command::new(daglang_bin()) +fn obligations_command_explicit_text_format_matches_default_output() { + let default_output = Command::new(daglang_bin()) .arg("obligations") .arg(makegen_file()) .current_dir(workspace_root()) .output() - .expect("failed to run daglang obligations"); - + .expect("failed to run daglang obligations default format"); assert!( - output.status.success(), - "obligations command failed: {}", - String::from_utf8_lossy(&output.stderr) + default_output.status.success(), + "default obligations command failed: {}", + String::from_utf8_lossy(&default_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("triplet_prepare_targets: 2")); - assert!(stdout.contains("triplet_execute_targets: 2")); - assert!(stdout.contains("triplet_parse_targets: 1")); - assert!(stdout.contains("triplet_total_targets: 5")); -} -#[test] -fn obligations_command_supports_json_output_format() { - let output = Command::new(daglang_bin()) + let explicit_text_output = Command::new(daglang_bin()) .arg("obligations") - .arg("--format=json") .arg(makegen_file()) + .arg("--format") + .arg("text") .current_dir(workspace_root()) .output() - .expect("failed to run daglang obligations --format=json"); - + .expect("failed to run daglang obligations --format text"); assert!( - output.status.success(), - "obligations --format=json command failed: {}", - String::from_utf8_lossy(&output.stderr) + explicit_text_output.status.success(), + "obligations --format text failed: {}", + String::from_utf8_lossy(&explicit_text_output.stderr) ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("\"summary\"")); - assert!(stdout.contains("\"transport\"")); - assert!(stdout.contains("\"pure_node_determinism\"")); - assert!(stdout.contains("\"resource_lifecycle\"")); - assert!(stdout.contains("\"triplet_total_targets\": 5")); -} - -#[test] -fn obligations_json_output_is_deterministic_for_same_input() { - let run_obligations_json = || { - Command::new(daglang_bin()) - .arg("obligations") - .arg("--format=json") - .arg(makegen_file()) - .current_dir(workspace_root()) - .output() - .expect("failed to run daglang obligations --format=json") - }; - let first = run_obligations_json(); - assert!( - first.status.success(), - "first obligations json run failed: {}", - String::from_utf8_lossy(&first.stderr) - ); - let second = run_obligations_json(); - assert!( - second.status.success(), - "second obligations json run failed: {}", - String::from_utf8_lossy(&second.stderr) - ); assert_eq!( - first.stdout, second.stdout, - "obligations json output should be byte-stable across runs" + default_output.stdout, explicit_text_output.stdout, + "explicit text format should match default obligations output" ); } #[test] -fn manifest_command_reports_non_zero_transport_and_lifecycle_obligations() { - let fixture = unique_temp_file("manifest_obligations"); +fn obligations_command_reports_transport_free_graph_counts() { + let fixture = unique_temp_file("obligations_transport_free"); 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 } -} +fn run() -> Unit { } "#, ) .expect("failed to write fixture"); let output = Command::new(daglang_bin()) - .arg("manifest") + .arg("obligations") .arg(&fixture) .current_dir(workspace_root()) .output() - .expect("failed to run daglang manifest on fixture"); + .expect("failed to run daglang obligations"); assert!( output.status.success(), - "manifest command failed: {}", + "obligations 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")); + assert!(stdout.contains("transport_execution_targets: 0")); + assert!(stdout.contains("pure_node_determinism_targets: 1")); + assert!(stdout.contains("service_transport_prepare_targets: 0")); + assert!(stdout.contains("resource_acquire_targets: 0")); 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"); +fn obligations_command_json_reports_transport_free_graph_counts() { + let fixture = unique_temp_file("obligations_transport_free_json"); 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 } -} + r#"module sample.obligations +fn run() -> Unit { } "#, ) .expect("failed to write fixture"); let output = Command::new(daglang_bin()) - .arg("manifest") + .arg("obligations") .arg(&fixture) + .arg("--format") + .arg("json") .current_dir(workspace_root()) .output() - .expect("failed to run daglang manifest on fixture"); + .expect("failed to run daglang obligations --format json"); assert!( output.status.success(), - "manifest command failed: {}", + "obligations --format json 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")); + let parsed: Value = serde_json::from_slice(&output.stdout) + .expect("obligations --format json should emit valid JSON"); + assert_eq!( + parsed + .get("transport_execution_targets") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + parsed + .get("pure_node_determinism_targets") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + parsed + .get("service_transport_prepare_targets") + .and_then(Value::as_u64), + Some(0) + ); + assert_eq!( + parsed + .get("resource_acquire_targets") + .and_then(Value::as_u64), + Some(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()) +fn manifest_and_obligations_commands_share_obligation_text_output() { + let manifest_output = Command::new(daglang_bin()) .arg("manifest") - .arg(&fixture) + .arg(makegen_file()) .current_dir(workspace_root()) .output() - .expect("failed to run daglang manifest on fixture"); - + .expect("failed to run daglang manifest"); assert!( - output.status.success(), + manifest_output.status.success(), "manifest command failed: {}", - String::from_utf8_lossy(&output.stderr) + String::from_utf8_lossy(&manifest_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_ascii_for_compiled_file_by_default() { - let output = Command::new(daglang_bin()) - .arg("viz") + let obligations_output = Command::new(daglang_bin()) + .arg("obligations") .arg(makegen_file()) .current_dir(workspace_root()) .output() - .expect("failed to run daglang viz file"); - + .expect("failed to run daglang obligations"); assert!( - output.status.success(), - "viz command failed: {}", - String::from_utf8_lossy(&output.stderr) + obligations_output.status.success(), + "obligations command failed: {}", + String::from_utf8_lossy(&obligations_output.stderr) + ); + + let manifest_stdout = String::from_utf8_lossy(&manifest_output.stdout); + let obligations_stdout = String::from_utf8_lossy(&obligations_output.stdout); + assert_eq!( + obligations_block(&manifest_stdout), + obligations_stdout, + "manifest obligation section should match standalone obligations output" ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert_no_stage_failures(&stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("ASCII DAG: daglang-compiled")); - assert!(stdout.contains("tools.makegen::render_makefile")); } #[test] -fn viz_command_supports_mermaid_format_flag() { - let output = Command::new(daglang_bin()) - .arg("viz") - .arg("--format") - .arg("mermaid") +fn obligations_command_json_output_is_deterministic_for_same_input() { + let first = Command::new(daglang_bin()) + .arg("obligations") .arg(makegen_file()) + .arg("--format") + .arg("json") .current_dir(workspace_root()) .output() - .expect("failed to run daglang viz --format mermaid file"); + .expect("failed to run first daglang obligations --format json"); + assert!( + first.status.success(), + "first obligations json command failed: {}", + String::from_utf8_lossy(&first.stderr) + ); + let second = Command::new(daglang_bin()) + .arg("obligations") + .arg(makegen_file()) + .arg("--format") + .arg("json") + .current_dir(workspace_root()) + .output() + .expect("failed to run second daglang obligations --format json"); assert!( - output.status.success(), - "viz --format mermaid command failed: {}", - String::from_utf8_lossy(&output.stderr) + second.status.success(), + "second obligations json command failed: {}", + String::from_utf8_lossy(&second.stderr) + ); + + assert_eq!( + first.stdout, second.stdout, + "obligations json output should be deterministic" + ); + assert_eq!( + first.stderr, second.stderr, + "obligations json stderr should be deterministic" ); - 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("daglang-compiled")); } #[test] -fn viz_command_supports_equals_mermaid_format_flag() { - let output = Command::new(daglang_bin()) - .arg("viz") - .arg("--format=mermaid") - .arg(makegen_file()) +fn obligations_command_curdir_suffix_target_matches_plain_relative_output() { + let plain = Command::new(daglang_bin()) + .arg("obligations") + .arg("dsl/tools/makegen.dag") .current_dir(workspace_root()) .output() - .expect("failed to run daglang viz --format=mermaid file"); + .expect("failed to run plain-target daglang obligations"); + assert!( + plain.status.success(), + "plain-target obligations command failed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + let curdir_suffix = Command::new(daglang_bin()) + .arg("obligations") + .arg("./dsl/tools/makegen.dag") + .current_dir(workspace_root()) + .output() + .expect("failed to run curdir-suffix-target daglang obligations"); assert!( - output.status.success(), - "viz --format=mermaid command failed: {}", - String::from_utf8_lossy(&output.stderr) + curdir_suffix.status.success(), + "curdir-suffix-target obligations command failed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix obligations stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix obligations stderr should match" ); - 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("daglang-compiled")); } #[test] -fn viz_command_explicit_ascii_matches_default_output() { - let default_output = Command::new(daglang_bin()) - .arg("viz") - .arg(makegen_file()) +fn obligations_command_relative_and_absolute_targets_are_equivalent() { + let relative = Command::new(daglang_bin()) + .arg("obligations") + .arg("dsl/tools/makegen.dag") .current_dir(workspace_root()) .output() - .expect("failed to run daglang viz with default format"); + .expect("failed to run relative-target daglang obligations"); assert!( - default_output.status.success(), - "default viz run failed: {}", - String::from_utf8_lossy(&default_output.stderr) + relative.status.success(), + "relative-target obligations command failed: {}", + String::from_utf8_lossy(&relative.stderr) ); - let explicit_ascii_output = Command::new(daglang_bin()) - .arg("viz") - .arg("--format") - .arg("ascii") + let absolute = Command::new(daglang_bin()) + .arg("obligations") .arg(makegen_file()) .current_dir(workspace_root()) .output() - .expect("failed to run daglang viz --format ascii"); + .expect("failed to run absolute-target daglang obligations"); assert!( - explicit_ascii_output.status.success(), - "explicit ascii viz run failed: {}", - String::from_utf8_lossy(&explicit_ascii_output.stderr) + absolute.status.success(), + "absolute-target obligations command failed: {}", + String::from_utf8_lossy(&absolute.stderr) ); assert_eq!( - default_output.stdout, explicit_ascii_output.stdout, - "explicit ascii format should match default viz output" + relative.stdout, absolute.stdout, + "relative and absolute obligations stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute obligations stderr should match" ); } #[test] -fn viz_command_equals_ascii_matches_default_output() { - let default_output = Command::new(daglang_bin()) - .arg("viz") - .arg(makegen_file()) +fn obligations_command_absolute_curdir_segment_target_matches_canonical_absolute_output() { + let curdir_segment_target = workspace_root() + .join("./dsl/./tools/../tools/makegen.dag") + .to_string_lossy() + .into_owned(); + + assert_makegen_target_variant_matches_canonical_output( + "obligations", + &curdir_segment_target, + &[], + "absolute curdir-segment", + ); +} + +#[test] +fn obligations_command_absolute_double_separator_target_matches_canonical_absolute_output() { + let double_separator_target = + format!("{}/dsl//tools///makegen.dag", workspace_root().display()); + + assert_makegen_target_variant_matches_canonical_output( + "obligations", + &double_separator_target, + &[], + "absolute double-separator", + ); +} + +#[test] +fn obligations_command_absolute_parent_segment_target_matches_canonical_absolute_output() { + let parent_segment_target = workspace_root() + .join("dsl/../dsl/tools/makegen.dag") + .to_string_lossy() + .into_owned(); + + assert_makegen_target_variant_matches_canonical_output( + "obligations", + &parent_segment_target, + &[], + "absolute parent-segment", + ); +} + +#[test] +fn obligations_command_absolute_parent_curdir_segment_target_matches_canonical_absolute_output() { + let parent_curdir_segment_target = workspace_root() + .join("dsl/tools/./../tools/makegen.dag") + .to_string_lossy() + .into_owned(); + + assert_makegen_target_variant_matches_canonical_output( + "obligations", + &parent_curdir_segment_target, + &[], + "absolute parent-curdir-segment", + ); +} + +#[test] +fn obligations_command_absolute_parent_double_separator_target_matches_canonical_absolute_output() { + let parent_double_separator_target = + format!("{}/dsl/..//dsl/tools/makegen.dag", workspace_root().display()); + + assert_makegen_target_variant_matches_canonical_output( + "obligations", + &parent_double_separator_target, + &[], + "absolute parent-double-separator", + ); +} + +#[test] +fn obligations_command_absolute_parent_curdir_double_separator_target_matches_canonical_absolute_output( +) { + let parent_curdir_double_separator_target = + format!("{}/dsl/./tools/..//tools/makegen.dag", workspace_root().display()); + + assert_makegen_target_variant_matches_canonical_output( + "obligations", + &parent_curdir_double_separator_target, + &[], + "absolute parent-curdir-double-separator", + ); +} + +#[test] +fn obligations_command_relative_and_absolute_uppercase_extension_targets_are_equivalent() { + let root = unique_temp_dir("obligations_relative_absolute_uppercase_extension"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let target = root.join("single.DAG"); + std::fs::write( + &target, + r#"module sample.obligations +fn run() -> Unit { } +"#, + ) + .expect("failed to write uppercase-extension obligations fixture"); + + let relative = Command::new(daglang_bin()) + .arg("obligations") + .arg("single.DAG") + .current_dir(&root) + .output() + .expect("failed to run relative uppercase obligations command"); + assert!( + relative.status.success(), + "relative uppercase obligations command failed: {}", + String::from_utf8_lossy(&relative.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("obligations") + .arg(&target) + .current_dir(&root) + .output() + .expect("failed to run absolute uppercase obligations command"); + assert!( + absolute.status.success(), + "absolute uppercase obligations command failed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute uppercase obligations stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute uppercase obligations stderr should match" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_relative_and_absolute_mixed_case_extension_targets_are_equivalent() { + let root = unique_temp_dir("obligations_relative_absolute_mixed_case_extension"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let target = root.join("single.DaG"); + std::fs::write( + &target, + r#"module sample.obligations +fn run() -> Unit { } +"#, + ) + .expect("failed to write mixed-case obligations fixture"); + + let relative = Command::new(daglang_bin()) + .arg("obligations") + .arg("single.DaG") + .current_dir(&root) + .output() + .expect("failed to run relative mixed-case obligations command"); + assert!( + relative.status.success(), + "relative mixed-case obligations command failed: {}", + String::from_utf8_lossy(&relative.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("obligations") + .arg(&target) + .current_dir(&root) + .output() + .expect("failed to run absolute mixed-case obligations command"); + assert!( + absolute.status.success(), + "absolute mixed-case obligations command failed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute mixed-case obligations stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute mixed-case obligations stderr should match" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_curdir_suffix_target_matches_plain_relative_output() { + let plain = Command::new(daglang_bin()) + .arg("obligations") + .arg("dsl/tools/makegen.dag") + .arg("--format") + .arg("json") .current_dir(workspace_root()) .output() - .expect("failed to run daglang viz with default format"); + .expect("failed to run plain-target daglang obligations --format json"); assert!( - default_output.status.success(), - "default viz run failed: {}", - String::from_utf8_lossy(&default_output.stderr) + plain.status.success(), + "plain-target obligations --format json failed: {}", + String::from_utf8_lossy(&plain.stderr) ); - let equals_ascii_output = Command::new(daglang_bin()) - .arg("viz") - .arg("--format=ascii") - .arg(makegen_file()) + let curdir_suffix = Command::new(daglang_bin()) + .arg("obligations") + .arg("./dsl/tools/makegen.dag") + .arg("--format") + .arg("json") .current_dir(workspace_root()) .output() - .expect("failed to run daglang viz --format=ascii"); + .expect("failed to run curdir-suffix-target daglang obligations --format json"); assert!( - equals_ascii_output.status.success(), - "equals ascii viz run failed: {}", - String::from_utf8_lossy(&equals_ascii_output.stderr) + curdir_suffix.status.success(), + "curdir-suffix-target obligations --format json failed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) ); assert_eq!( - default_output.stdout, equals_ascii_output.stdout, - "equals ascii format should match default viz output" + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix obligations json stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix obligations json stderr should match" ); } #[test] -fn viz_command_ascii_output_is_deterministic_for_same_input() { - let first = Command::new(daglang_bin()) - .arg("viz") - .arg(makegen_file()) +fn obligations_command_json_relative_and_absolute_targets_are_equivalent() { + let relative = Command::new(daglang_bin()) + .arg("obligations") + .arg("dsl/tools/makegen.dag") + .arg("--format") + .arg("json") .current_dir(workspace_root()) .output() - .expect("failed to run first daglang viz"); + .expect("failed to run relative-target daglang obligations --format json"); assert!( - first.status.success(), - "first viz run failed: {}", - String::from_utf8_lossy(&first.stderr) + relative.status.success(), + "relative-target obligations --format json failed: {}", + String::from_utf8_lossy(&relative.stderr) ); - let second = Command::new(daglang_bin()) - .arg("viz") + let absolute = Command::new(daglang_bin()) + .arg("obligations") .arg(makegen_file()) + .arg("--format") + .arg("json") .current_dir(workspace_root()) .output() - .expect("failed to run second daglang viz"); + .expect("failed to run absolute-target daglang obligations --format json"); assert!( - second.status.success(), - "second viz run failed: {}", - String::from_utf8_lossy(&second.stderr) + absolute.status.success(), + "absolute-target obligations --format json failed: {}", + String::from_utf8_lossy(&absolute.stderr) ); assert_eq!( - first.stdout, second.stdout, - "default ascii viz output should be byte-stable across runs" + relative.stdout, absolute.stdout, + "relative and absolute obligations json stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute obligations json stderr should match" + ); +} + +#[test] +fn obligations_command_json_absolute_double_separator_target_matches_canonical_absolute_output() { + let double_separator_target = + format!("{}/dsl//tools///makegen.dag", workspace_root().display()); + + assert_makegen_target_variant_matches_canonical_output( + "obligations", + &double_separator_target, + &["--format", "json"], + "absolute double-separator", ); } +#[test] +fn obligations_command_json_absolute_curdir_segment_target_matches_canonical_absolute_output() { + let curdir_segment_target = workspace_root() + .join("./dsl/./tools/../tools/makegen.dag") + .to_string_lossy() + .into_owned(); + + assert_makegen_target_variant_matches_canonical_output( + "obligations", + &curdir_segment_target, + &["--format", "json"], + "absolute curdir-segment", + ); +} + +#[test] +fn obligations_command_json_absolute_parent_segment_target_matches_canonical_absolute_output() { + let parent_segment_target = workspace_root() + .join("dsl/../dsl/tools/makegen.dag") + .to_string_lossy() + .into_owned(); + + assert_makegen_target_variant_matches_canonical_output( + "obligations", + &parent_segment_target, + &["--format", "json"], + "absolute parent-segment", + ); +} + +#[test] +fn obligations_command_json_absolute_parent_curdir_segment_target_matches_canonical_absolute_output( +) { + let parent_curdir_segment_target = workspace_root() + .join("dsl/tools/./../tools/makegen.dag") + .to_string_lossy() + .into_owned(); + + assert_makegen_target_variant_matches_canonical_output( + "obligations", + &parent_curdir_segment_target, + &["--format", "json"], + "absolute parent-curdir-segment", + ); +} + +#[test] +fn obligations_command_json_absolute_parent_double_separator_target_matches_canonical_absolute_output( +) { + let parent_double_separator_target = + format!("{}/dsl/..//dsl/tools/makegen.dag", workspace_root().display()); + + assert_makegen_target_variant_matches_canonical_output( + "obligations", + &parent_double_separator_target, + &["--format", "json"], + "absolute parent-double-separator", + ); +} + +#[test] +fn obligations_command_json_absolute_parent_curdir_double_separator_target_matches_canonical_absolute_output( +) { + let parent_curdir_double_separator_target = + format!("{}/dsl/./tools/..//tools/makegen.dag", workspace_root().display()); + + assert_makegen_target_variant_matches_canonical_output( + "obligations", + &parent_curdir_double_separator_target, + &["--format", "json"], + "absolute parent-curdir-double-separator", + ); +} + +#[test] +fn obligations_command_json_relative_and_absolute_mixed_case_extension_targets_are_equivalent() { + let root = unique_temp_dir("obligations_json_relative_absolute_mixed_case_extension"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let target = root.join("single.DaG"); + std::fs::write( + &target, + r#"module sample.obligations +fn run() -> Unit { } +"#, + ) + .expect("failed to write mixed-case obligations fixture"); + + let relative = Command::new(daglang_bin()) + .arg("obligations") + .arg("single.DaG") + .arg("--format") + .arg("json") + .current_dir(&root) + .output() + .expect("failed to run relative mixed-case obligations json command"); + assert!( + relative.status.success(), + "relative mixed-case obligations json command failed: {}", + String::from_utf8_lossy(&relative.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("obligations") + .arg(&target) + .arg("--format") + .arg("json") + .current_dir(&root) + .output() + .expect("failed to run absolute mixed-case obligations json command"); + assert!( + absolute.status.success(), + "absolute mixed-case obligations json command failed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute mixed-case obligations json stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute mixed-case obligations json stderr should match" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_relative_and_absolute_uppercase_extension_targets_are_equivalent() { + let root = unique_temp_dir("obligations_json_relative_absolute_uppercase_extension"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let target = root.join("single.DAG"); + std::fs::write( + &target, + r#"module sample.obligations +fn run() -> Unit { } +"#, + ) + .expect("failed to write uppercase obligations fixture"); + + let relative = Command::new(daglang_bin()) + .arg("obligations") + .arg("single.DAG") + .arg("--format") + .arg("json") + .current_dir(&root) + .output() + .expect("failed to run relative uppercase obligations json command"); + assert!( + relative.status.success(), + "relative uppercase obligations json command failed: {}", + String::from_utf8_lossy(&relative.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("obligations") + .arg(&target) + .arg("--format") + .arg("json") + .current_dir(&root) + .output() + .expect("failed to run absolute uppercase obligations json command"); + assert!( + absolute.status.success(), + "absolute uppercase obligations json command failed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute uppercase obligations json stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute uppercase obligations json stderr should match" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_uppercase_extension_single_file_target_matches_curdir_suffix_output() { + let root = unique_temp_dir("obligations_uppercase_extension_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let target = root.join("single.DAG"); + std::fs::write( + &target, + r#"module sample.obligations +fn run() -> Unit { } +"#, + ) + .expect("failed to write uppercase-extension obligations fixture"); + + let plain = Command::new(daglang_bin()) + .arg("obligations") + .arg("single.DAG") + .current_dir(&root) + .output() + .expect("failed to run obligations on plain uppercase target"); + assert!( + plain.status.success(), + "plain uppercase obligations command failed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("obligations") + .arg("./single.DAG") + .current_dir(&root) + .output() + .expect("failed to run obligations on curdir-suffix uppercase target"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix uppercase obligations command failed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix uppercase obligations stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix uppercase obligations stderr should match" + ); + assert!( + String::from_utf8_lossy(&plain.stdout).contains("pure_node_determinism_targets: 1"), + "obligations output should include deterministic-node count for uppercase extension fixture: {}", + String::from_utf8_lossy(&plain.stdout) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_mixed_case_extension_single_file_target_matches_curdir_suffix_output() +{ + let root = unique_temp_dir("obligations_json_mixed_case_extension_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let target = root.join("single.DaG"); + std::fs::write( + &target, + r#"module sample.obligations +fn run() -> Unit { } +"#, + ) + .expect("failed to write mixed-case obligations fixture"); + + let plain = Command::new(daglang_bin()) + .arg("obligations") + .arg("single.DaG") + .arg("--format") + .arg("json") + .current_dir(&root) + .output() + .expect("failed to run obligations --format json on plain mixed-case target"); + assert!( + plain.status.success(), + "plain mixed-case obligations json command failed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("obligations") + .arg("./single.DaG") + .arg("--format") + .arg("json") + .current_dir(&root) + .output() + .expect("failed to run obligations --format json on curdir-suffix mixed-case target"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix mixed-case obligations json command failed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix mixed-case obligations json stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix mixed-case obligations json stderr should match" + ); + let parsed: Value = + serde_json::from_slice(&plain.stdout).expect("obligations json output should be valid"); + assert_eq!( + parsed + .get("pure_node_determinism_targets") + .and_then(Value::as_u64), + Some(1) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_directory_named_dag_extension_is_invalid_single_file_target() { + let root = unique_temp_dir("obligations_directory_named_dag_extension"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + "bundle.dag", + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_directory_named_uppercase_dag_extension_is_invalid_single_file_target() { + let root = unique_temp_dir("obligations_directory_named_uppercase_dag_extension"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DAG directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + "bundle.DAG", + &dag_dir, + Some("broken.dag:2:3"), + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_directory_named_dag_extension_is_invalid_single_file_target() { + let root = unique_temp_dir("obligations_absolute_directory_named_dag_extension"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + let absolute_input = dag_dir.to_string_lossy().into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &absolute_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_parent_segment_directory_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir("obligations_absolute_parent_segment_directory_named_dag_extension"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + let parent_segment_input = root + .join("nested/../bundle.dag") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &parent_segment_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_parent_double_separator_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_absolute_parent_double_separator_directory_named_mixed_case_dag_extension", + ); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let root_input = root.to_string_lossy(); + let parent_double_separator_input = format!("{root_input}/nested/..//bundle.DaG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &parent_double_separator_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_absolute_parent_double_separator_symlink_named_dag_extension_is_invalid_single_file_target( +) { + let root = + unique_temp_dir("obligations_absolute_parent_double_separator_symlink_named_dag_extension"); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let root_input = root.to_string_lossy(); + let parent_double_separator_input = format!("{root_input}/nested/..//bundle_link.dag"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &parent_double_separator_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_parent_curdir_segment_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_absolute_parent_curdir_segment_directory_named_uppercase_dag_extension", + ); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let parent_curdir_segment_input = root + .join("nested/./../bundle.DAG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &parent_curdir_segment_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_absolute_parent_curdir_segment_symlink_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_absolute_parent_curdir_segment_symlink_named_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let parent_curdir_segment_input = root + .join("nested/./../bundle_link.dag") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &parent_curdir_segment_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_parent_curdir_double_separator_directory_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_absolute_parent_curdir_double_separator_directory_named_dag_extension", + ); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + let root_input = root.to_string_lossy(); + let parent_curdir_double_separator_input = + format!("{root_input}/nested/./..//bundle.dag"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &parent_curdir_double_separator_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_absolute_parent_curdir_double_separator_symlink_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_absolute_parent_curdir_double_separator_symlink_named_mixed_case_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let root_input = root.to_string_lossy(); + let parent_curdir_double_separator_input = + format!("{root_input}/nested/./..//bundle_link.DaG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &parent_curdir_double_separator_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_parent_curdir_double_separator_directory_alias_matches_canonical_absolute_alias_output( +) { + let root = unique_temp_dir( + "obligations_absolute_parent_curdir_double_separator_directory_alias_output_parity", + ); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + let canonical_input = dag_dir.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/./..//bundle.dag", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + &canonical_input, + &variant_input, + &[], + "absolute parent-curdir-double-separator directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_parent_curdir_segment_directory_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("obligations_absolute_parent_curdir_segment_directory_alias_output_parity"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let canonical_input = dag_dir.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/./../bundle.DAG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + &canonical_input, + &variant_input, + &[], + "absolute parent-curdir-segment directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_parent_double_separator_directory_alias_matches_canonical_absolute_alias_output( +) { + let root = unique_temp_dir( + "obligations_absolute_parent_double_separator_directory_alias_output_parity", + ); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let canonical_input = dag_dir.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/..//bundle.DaG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + &canonical_input, + &variant_input, + &[], + "absolute parent-double-separator directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_parent_segment_directory_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("obligations_absolute_parent_segment_directory_alias_output_parity"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + let canonical_input = dag_dir.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/../bundle.dag", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + &canonical_input, + &variant_input, + &[], + "absolute parent-segment directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_curdir_segment_directory_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("obligations_absolute_curdir_segment_directory_alias_output_parity"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let canonical_input = dag_dir.to_string_lossy().into_owned(); + let variant_input = root.join("./bundle.DaG").to_string_lossy().into_owned(); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + &canonical_input, + &variant_input, + &[], + "absolute curdir-segment directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_double_separator_directory_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("obligations_absolute_double_separator_directory_alias_output_parity"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let canonical_input = dag_dir.to_string_lossy().into_owned(); + let variant_input = format!("{}//bundle.DAG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + &canonical_input, + &variant_input, + &[], + "absolute double-separator directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_relative_and_absolute_directory_alias_fail_outputs_are_equivalent() { + let root = + unique_temp_dir("obligations_relative_absolute_directory_alias_failure_output_parity"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let absolute_input = dag_dir.to_string_lossy().into_owned(); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + "bundle.DAG", + &absolute_input, + &[], + "absolute directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_relative_directory_alias_normalized_spelling_fail_outputs_match_canonical() { + let root = unique_temp_dir( + "obligations_relative_directory_alias_normalized_spelling_failure_output_parity", + ); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + assert_single_target_command_failure_outputs_match_for_variants( + "obligations", + &root, + "bundle.dag", + &[ + ("./bundle.dag", "curdir-suffix alias"), + ("nested/../bundle.dag", "parent-segment alias"), + ("nested/./../bundle.dag", "parent-curdir alias"), + ("nested/..//bundle.dag", "parent-double-separator alias"), + ], + &[], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_relative_and_absolute_symlink_alias_fail_outputs_are_equivalent() { + let root = + unique_temp_dir("obligations_relative_absolute_symlink_alias_failure_output_parity"); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let absolute_input = symlink_path.to_string_lossy().into_owned(); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + "bundle_link.DAG", + &absolute_input, + &[], + "absolute symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_relative_symlink_alias_normalized_spelling_fail_outputs_match_canonical( +) { + let root = unique_temp_dir( + "obligations_json_relative_symlink_alias_normalized_spelling_failure_output_parity", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + assert_single_target_command_failure_outputs_match_for_variants( + "obligations", + &root, + "bundle_link.DAG", + &[ + ("./bundle_link.DAG", "curdir-suffix alias"), + ("nested/../bundle_link.DAG", "parent-segment alias"), + ("nested/./../bundle_link.DAG", "parent-curdir alias"), + ("nested/..//bundle_link.DAG", "parent-double-separator alias"), + ], + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_curdir_segment_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = + unique_temp_dir("obligations_absolute_curdir_segment_directory_named_mixed_case_dag_extension"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let curdir_segment_input = root.join("./bundle.DaG").to_string_lossy().into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &curdir_segment_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_absolute_curdir_segment_symlink_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_absolute_curdir_segment_symlink_named_mixed_case_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let curdir_segment_input = root + .join("./bundle_link.DaG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &curdir_segment_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_absolute_double_separator_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_absolute_double_separator_directory_named_uppercase_dag_extension", + ); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let root_input = root.to_string_lossy(); + let double_separator_input = format!("{root_input}//bundle.DAG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &double_separator_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_absolute_double_separator_symlink_named_dag_extension_is_invalid_single_file_target( +) { + let root = + unique_temp_dir("obligations_absolute_double_separator_symlink_named_dag_extension"); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let root_input = root.to_string_lossy(); + let double_separator_input = format!("{root_input}//bundle_link.dag"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &double_separator_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_absolute_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = + unique_temp_dir("obligations_absolute_symlink_named_uppercase_dag_extension"); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let absolute_input = symlink_path.to_string_lossy().into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &absolute_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_absolute_parent_segment_symlink_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_absolute_parent_segment_symlink_named_mixed_case_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let parent_segment_input = root + .join("nested/../bundle_link.DaG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + &parent_segment_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_curdir_suffix_directory_named_dag_extension_is_invalid_single_file_target() { + let root = unique_temp_dir("obligations_curdir_suffix_directory_named_dag_extension"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + "./bundle.dag", + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_curdir_suffix_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = + unique_temp_dir("obligations_curdir_suffix_directory_named_mixed_case_dag_extension"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DaG directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + "./bundle.DaG", + &dag_dir, + Some("broken.dag:2:3"), + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_symlink_directory_named_dag_extension_is_invalid_single_file_target() { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("obligations_symlink_directory_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory root"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + "link.dag", + &link_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_symlink_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = + unique_temp_dir("obligations_symlink_directory_named_mixed_case_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DaG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DaG directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + "link.DaG", + &link_dir, + Some("broken.dag:2:3"), + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_curdir_suffix_symlink_named_dag_extension_is_invalid_single_file_target() { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("obligations_curdir_suffix_symlink_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory root"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + "./link.dag", + &link_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_curdir_suffix_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = + unique_temp_dir("obligations_curdir_suffix_symlink_named_uppercase_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DAG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DAG directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "obligations", + &root, + "./link.DAG", + &link_dir, + Some("broken.dag:2:3"), + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_directory_named_dag_extension_is_invalid_single_file_target() { + let root = unique_temp_dir("obligations_json_directory_named_dag_extension"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + "bundle.dag", + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir("obligations_json_directory_named_uppercase_dag_extension"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DAG directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + "bundle.DAG", + &dag_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir("obligations_json_directory_named_mixed_case_dag_extension"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DaG directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + "bundle.DaG", + &dag_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_absolute_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir("obligations_json_absolute_directory_named_uppercase_dag_extension"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let absolute_input = dag_dir.to_string_lossy().into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &absolute_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_absolute_parent_segment_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_parent_segment_directory_named_uppercase_dag_extension", + ); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let parent_segment_input = root + .join("nested/../bundle.DAG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &parent_segment_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_parent_double_separator_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_parent_double_separator_symlink_named_uppercase_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let root_input = root.to_string_lossy(); + let parent_double_separator_input = format!("{root_input}/nested/..//bundle_link.DAG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &parent_double_separator_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_absolute_parent_double_separator_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_parent_double_separator_directory_named_uppercase_dag_extension", + ); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let root_input = root.to_string_lossy(); + let parent_double_separator_input = format!("{root_input}/nested/..//bundle.DAG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &parent_double_separator_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_parent_curdir_segment_symlink_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_parent_curdir_segment_symlink_named_mixed_case_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let parent_curdir_segment_input = root + .join("nested/./../bundle_link.DaG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &parent_curdir_segment_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_absolute_parent_curdir_segment_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_parent_curdir_segment_directory_named_mixed_case_dag_extension", + ); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let parent_curdir_segment_input = root + .join("nested/./../bundle.DaG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &parent_curdir_segment_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_parent_curdir_double_separator_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_parent_curdir_double_separator_symlink_named_uppercase_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let root_input = root.to_string_lossy(); + let parent_curdir_double_separator_input = + format!("{root_input}/nested/./..//bundle_link.DAG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &parent_curdir_double_separator_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_absolute_parent_curdir_double_separator_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_parent_curdir_double_separator_directory_named_uppercase_dag_extension", + ); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let root_input = root.to_string_lossy(); + let parent_curdir_double_separator_input = + format!("{root_input}/nested/./..//bundle.DAG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &parent_curdir_double_separator_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_parent_curdir_double_separator_symlink_alias_matches_canonical_absolute_alias_output( +) { + let root = unique_temp_dir( + "obligations_json_absolute_parent_curdir_double_separator_symlink_alias_output_parity", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let canonical_input = symlink_path.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/./..//bundle_link.DAG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + &canonical_input, + &variant_input, + &["--format", "json"], + "absolute parent-curdir-double-separator symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_parent_curdir_segment_symlink_alias_matches_canonical_absolute_alias_output( +) { + let root = unique_temp_dir( + "obligations_json_absolute_parent_curdir_segment_symlink_alias_output_parity", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let canonical_input = symlink_path.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/./../bundle_link.DaG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + &canonical_input, + &variant_input, + &["--format", "json"], + "absolute parent-curdir-segment symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_parent_double_separator_symlink_alias_matches_canonical_absolute_alias_output( +) { + let root = unique_temp_dir( + "obligations_json_absolute_parent_double_separator_symlink_alias_output_parity", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let canonical_input = symlink_path.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/..//bundle_link.dag", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + &canonical_input, + &variant_input, + &["--format", "json"], + "absolute parent-double-separator symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_parent_segment_symlink_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("obligations_json_absolute_parent_segment_symlink_alias_output_parity"); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let canonical_input = symlink_path.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/../bundle_link.DAG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + &canonical_input, + &variant_input, + &["--format", "json"], + "absolute parent-segment symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_curdir_segment_symlink_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("obligations_json_absolute_curdir_segment_symlink_alias_output_parity"); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let canonical_input = symlink_path.to_string_lossy().into_owned(); + let variant_input = root + .join("./bundle_link.dag") + .to_string_lossy() + .into_owned(); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + &canonical_input, + &variant_input, + &["--format", "json"], + "absolute curdir-segment symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_double_separator_symlink_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("obligations_json_absolute_double_separator_symlink_alias_output_parity"); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let canonical_input = symlink_path.to_string_lossy().into_owned(); + let variant_input = format!("{}//bundle_link.DaG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + &canonical_input, + &variant_input, + &["--format", "json"], + "absolute double-separator symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_relative_and_absolute_symlink_alias_fail_outputs_are_equivalent() { + let root = + unique_temp_dir("obligations_json_relative_absolute_symlink_alias_failure_output_parity"); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let absolute_input = symlink_path.to_string_lossy().into_owned(); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + "bundle_link.DaG", + &absolute_input, + &["--format", "json"], + "absolute symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_relative_and_absolute_directory_alias_fail_outputs_are_equivalent() { + let root = + unique_temp_dir("obligations_json_relative_absolute_directory_alias_failure_output_parity"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let absolute_input = dag_dir.to_string_lossy().into_owned(); + assert_single_target_command_failure_outputs_match_for_targets( + "obligations", + &root, + "bundle.DaG", + &absolute_input, + &["--format", "json"], + "absolute directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_parent_segment_symlink_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_parent_segment_symlink_named_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let parent_segment_input = root + .join("nested/../bundle_link.dag") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &parent_segment_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_symlink_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_symlink_named_mixed_case_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let absolute_input = symlink_path.to_string_lossy().into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &absolute_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_double_separator_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_double_separator_symlink_named_uppercase_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let root_input = root.to_string_lossy(); + let double_separator_input = format!("{root_input}//bundle_link.DAG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &double_separator_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_absolute_curdir_segment_symlink_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_curdir_segment_symlink_named_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let curdir_segment_input = root + .join("./bundle_link.dag") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &curdir_segment_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_absolute_curdir_segment_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_curdir_segment_directory_named_uppercase_dag_extension", + ); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let curdir_segment_input = root.join("./bundle.DAG").to_string_lossy().into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &curdir_segment_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_absolute_double_separator_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "obligations_json_absolute_double_separator_directory_named_mixed_case_dag_extension", + ); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let root_input = root.to_string_lossy(); + let double_separator_input = format!("{root_input}//bundle.DaG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + &double_separator_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_curdir_suffix_directory_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir("obligations_json_curdir_suffix_directory_named_dag_extension"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + "./bundle.dag", + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_curdir_suffix_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = + unique_temp_dir("obligations_json_curdir_suffix_directory_named_mixed_case_dag_extension"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DaG directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + "./bundle.DaG", + &dag_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_symlink_directory_named_dag_extension_is_invalid_single_file_target() { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("obligations_json_symlink_directory_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory root"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + "link.dag", + &link_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_curdir_suffix_symlink_named_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("obligations_json_curdir_suffix_symlink_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory root"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + "./link.dag", + &link_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_symlink_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = + unique_temp_dir("obligations_json_symlink_directory_named_mixed_case_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DaG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DaG directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + "link.DaG", + &link_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_curdir_suffix_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir( + "obligations_json_curdir_suffix_symlink_named_uppercase_dag_extension", + ); + let real_dir = root.join("real"); + let link_dir = root.join("link.DAG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DAG directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + "./link.DAG", + &link_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_symlink_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("obligations_json_symlink_directory_named_uppercase_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DAG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DAG directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + "link.DAG", + &link_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn obligations_command_json_curdir_suffix_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = + unique_temp_dir("obligations_json_curdir_suffix_directory_named_uppercase_dag_extension"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DAG directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + "./bundle.DAG", + &dag_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn obligations_command_json_curdir_suffix_symlink_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir( + "obligations_json_curdir_suffix_symlink_named_mixed_case_dag_extension", + ); + let real_dir = root.join("real"); + let link_dir = root.join("link.DaG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DaG directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "obligations", + &root, + "./link.DaG", + &link_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_shows_transport_expansion_for_makegen() { + let output = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(makegen_file()) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang show-triplets"); + + assert!( + output.status.success(), + "show-triplets command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("TransportTriplets:")); + assert!(stdout.contains("prepare_read_makegen")); + assert!(stdout.contains("execute_read_makegen")); +} + +#[test] +fn show_triplets_command_json_format_emits_triplet_list() { + let output = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(makegen_file()) + .arg("--format") + .arg("json") + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang show-triplets --format json"); + + assert!( + output.status.success(), + "show-triplets --format json failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let parsed: Value = serde_json::from_slice(&output.stdout) + .expect("show-triplets --format json should emit valid JSON"); + let triplets = parsed + .get("triplets") + .and_then(Value::as_array) + .expect("triplets should be a JSON array"); + assert!( + !triplets.is_empty(), + "triplet list should include at least one transport chain" + ); +} + +#[test] +fn show_triplets_command_reports_none_for_transport_free_graph() { + let fixture = unique_temp_file("show_triplets_none"); + std::fs::write( + &fixture, + r#"module sample.triplets +fn run() -> Unit { } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(&fixture) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang show-triplets"); + + assert!( + output.status.success(), + "show-triplets command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("TransportTriplets:")); + assert!(stdout.contains("(none)")); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn show_triplets_command_json_reports_empty_array_for_transport_free_graph() { + let fixture = unique_temp_file("show_triplets_json_none"); + std::fs::write( + &fixture, + r#"module sample.triplets +fn run() -> Unit { } +"#, + ) + .expect("failed to write fixture"); + + let output = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(&fixture) + .arg("--format") + .arg("json") + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang show-triplets --format json"); + + assert!( + output.status.success(), + "show-triplets --format json failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let parsed: Value = serde_json::from_slice(&output.stdout) + .expect("show-triplets --format json should emit valid JSON"); + let triplets = parsed + .get("triplets") + .and_then(Value::as_array) + .expect("triplets should be a JSON array"); + assert!(triplets.is_empty(), "transport-free graph should have no triplets"); + + std::fs::remove_file(fixture).expect("failed to cleanup fixture"); +} + +#[test] +fn show_triplets_command_explicit_text_format_matches_default_output() { + let default_output = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(makegen_file()) + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang show-triplets default format"); + assert!( + default_output.status.success(), + "default show-triplets command failed: {}", + String::from_utf8_lossy(&default_output.stderr) + ); + + let explicit_text_output = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(makegen_file()) + .arg("--format") + .arg("text") + .current_dir(workspace_root()) + .output() + .expect("failed to run daglang show-triplets --format text"); + assert!( + explicit_text_output.status.success(), + "show-triplets --format text failed: {}", + String::from_utf8_lossy(&explicit_text_output.stderr) + ); + + assert_eq!( + default_output.stdout, explicit_text_output.stdout, + "explicit text format should match default show-triplets output" + ); +} + +#[test] +fn show_triplets_command_json_output_is_deterministic_for_same_input() { + let first = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(makegen_file()) + .arg("--format") + .arg("json") + .current_dir(workspace_root()) + .output() + .expect("failed to run first daglang show-triplets --format json"); + assert!( + first.status.success(), + "first show-triplets json command failed: {}", + String::from_utf8_lossy(&first.stderr) + ); + + let second = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(makegen_file()) + .arg("--format") + .arg("json") + .current_dir(workspace_root()) + .output() + .expect("failed to run second daglang show-triplets --format json"); + assert!( + second.status.success(), + "second show-triplets json command failed: {}", + String::from_utf8_lossy(&second.stderr) + ); + + assert_eq!( + first.stdout, second.stdout, + "show-triplets json output should be deterministic" + ); + assert_eq!( + first.stderr, second.stderr, + "show-triplets json stderr should be deterministic" + ); +} + +#[test] +fn show_triplets_command_curdir_suffix_target_matches_plain_relative_output() { + let plain = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("dsl/tools/makegen.dag") + .current_dir(workspace_root()) + .output() + .expect("failed to run plain-target daglang show-triplets"); + assert!( + plain.status.success(), + "plain-target show-triplets command failed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("./dsl/tools/makegen.dag") + .current_dir(workspace_root()) + .output() + .expect("failed to run curdir-suffix-target daglang show-triplets"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix-target show-triplets command failed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix show-triplets stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix show-triplets stderr should match" + ); +} + +#[test] +fn show_triplets_command_relative_and_absolute_targets_are_equivalent() { + let relative = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("dsl/tools/makegen.dag") + .current_dir(workspace_root()) + .output() + .expect("failed to run relative-target daglang show-triplets"); + assert!( + relative.status.success(), + "relative-target show-triplets command failed: {}", + String::from_utf8_lossy(&relative.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(makegen_file()) + .current_dir(workspace_root()) + .output() + .expect("failed to run absolute-target daglang show-triplets"); + assert!( + absolute.status.success(), + "absolute-target show-triplets command failed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute show-triplets stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute show-triplets stderr should match" + ); +} + +#[test] +fn show_triplets_command_absolute_curdir_segment_target_matches_canonical_absolute_output() { + let curdir_segment_target = workspace_root() + .join("./dsl/./tools/../tools/makegen.dag") + .to_string_lossy() + .into_owned(); + + assert_makegen_target_variant_matches_canonical_output( + "show-triplets", + &curdir_segment_target, + &[], + "absolute curdir-segment", + ); +} + +#[test] +fn show_triplets_command_absolute_double_separator_target_matches_canonical_absolute_output() { + let double_separator_target = + format!("{}/dsl//tools///makegen.dag", workspace_root().display()); + + assert_makegen_target_variant_matches_canonical_output( + "show-triplets", + &double_separator_target, + &[], + "absolute double-separator", + ); +} + +#[test] +fn show_triplets_command_absolute_parent_segment_target_matches_canonical_absolute_output() { + let parent_segment_target = workspace_root() + .join("dsl/../dsl/tools/makegen.dag") + .to_string_lossy() + .into_owned(); + + assert_makegen_target_variant_matches_canonical_output( + "show-triplets", + &parent_segment_target, + &[], + "absolute parent-segment", + ); +} + +#[test] +fn show_triplets_command_absolute_parent_curdir_segment_target_matches_canonical_absolute_output() { + let parent_curdir_segment_target = workspace_root() + .join("dsl/tools/./../tools/makegen.dag") + .to_string_lossy() + .into_owned(); + + assert_makegen_target_variant_matches_canonical_output( + "show-triplets", + &parent_curdir_segment_target, + &[], + "absolute parent-curdir-segment", + ); +} + +#[test] +fn show_triplets_command_absolute_parent_double_separator_target_matches_canonical_absolute_output() { + let parent_double_separator_target = + format!("{}/dsl/..//dsl/tools/makegen.dag", workspace_root().display()); + + assert_makegen_target_variant_matches_canonical_output( + "show-triplets", + &parent_double_separator_target, + &[], + "absolute parent-double-separator", + ); +} + +#[test] +fn show_triplets_command_absolute_parent_curdir_double_separator_target_matches_canonical_absolute_output( +) { + let parent_curdir_double_separator_target = + format!("{}/dsl/./tools/..//tools/makegen.dag", workspace_root().display()); + + assert_makegen_target_variant_matches_canonical_output( + "show-triplets", + &parent_curdir_double_separator_target, + &[], + "absolute parent-curdir-double-separator", + ); +} + +#[test] +fn show_triplets_command_relative_and_absolute_uppercase_extension_targets_are_equivalent() { + let root = unique_temp_dir("show_triplets_relative_absolute_uppercase_extension"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let target = root.join("single.DAG"); + std::fs::write( + &target, + r#"module sample.triplets +fn run() -> Unit { } +"#, + ) + .expect("failed to write uppercase-extension show-triplets fixture"); + + let relative = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("single.DAG") + .current_dir(&root) + .output() + .expect("failed to run relative uppercase show-triplets command"); + assert!( + relative.status.success(), + "relative uppercase show-triplets command failed: {}", + String::from_utf8_lossy(&relative.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(&target) + .current_dir(&root) + .output() + .expect("failed to run absolute uppercase show-triplets command"); + assert!( + absolute.status.success(), + "absolute uppercase show-triplets command failed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute uppercase show-triplets stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute uppercase show-triplets stderr should match" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_relative_and_absolute_mixed_case_extension_targets_are_equivalent() { + let root = unique_temp_dir("show_triplets_relative_absolute_mixed_case_extension"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let target = root.join("single.DaG"); + std::fs::write( + &target, + r#"module sample.triplets +fn run() -> Unit { } +"#, + ) + .expect("failed to write mixed-case show-triplets fixture"); + + let relative = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("single.DaG") + .current_dir(&root) + .output() + .expect("failed to run relative mixed-case show-triplets command"); + assert!( + relative.status.success(), + "relative mixed-case show-triplets command failed: {}", + String::from_utf8_lossy(&relative.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(&target) + .current_dir(&root) + .output() + .expect("failed to run absolute mixed-case show-triplets command"); + assert!( + absolute.status.success(), + "absolute mixed-case show-triplets command failed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute mixed-case show-triplets stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute mixed-case show-triplets stderr should match" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_curdir_suffix_target_matches_plain_relative_output() { + let plain = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("dsl/tools/makegen.dag") + .arg("--format") + .arg("json") + .current_dir(workspace_root()) + .output() + .expect("failed to run plain-target daglang show-triplets --format json"); + assert!( + plain.status.success(), + "plain-target show-triplets --format json failed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("./dsl/tools/makegen.dag") + .arg("--format") + .arg("json") + .current_dir(workspace_root()) + .output() + .expect("failed to run curdir-suffix-target daglang show-triplets --format json"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix-target show-triplets --format json failed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix show-triplets json stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix show-triplets json stderr should match" + ); +} + +#[test] +fn show_triplets_command_json_relative_and_absolute_targets_are_equivalent() { + let relative = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("dsl/tools/makegen.dag") + .arg("--format") + .arg("json") + .current_dir(workspace_root()) + .output() + .expect("failed to run relative-target daglang show-triplets --format json"); + assert!( + relative.status.success(), + "relative-target show-triplets --format json failed: {}", + String::from_utf8_lossy(&relative.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(makegen_file()) + .arg("--format") + .arg("json") + .current_dir(workspace_root()) + .output() + .expect("failed to run absolute-target daglang show-triplets --format json"); + assert!( + absolute.status.success(), + "absolute-target show-triplets --format json failed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute show-triplets json stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute show-triplets json stderr should match" + ); +} + +#[test] +fn show_triplets_command_json_absolute_double_separator_target_matches_canonical_absolute_output() { + let double_separator_target = + format!("{}/dsl//tools///makegen.dag", workspace_root().display()); + + assert_makegen_target_variant_matches_canonical_output( + "show-triplets", + &double_separator_target, + &["--format", "json"], + "absolute double-separator", + ); +} + +#[test] +fn show_triplets_command_json_absolute_curdir_segment_target_matches_canonical_absolute_output() { + let curdir_segment_target = workspace_root() + .join("./dsl/./tools/../tools/makegen.dag") + .to_string_lossy() + .into_owned(); + + assert_makegen_target_variant_matches_canonical_output( + "show-triplets", + &curdir_segment_target, + &["--format", "json"], + "absolute curdir-segment", + ); +} + +#[test] +fn show_triplets_command_json_absolute_parent_segment_target_matches_canonical_absolute_output() { + let parent_segment_target = workspace_root() + .join("dsl/../dsl/tools/makegen.dag") + .to_string_lossy() + .into_owned(); + + assert_makegen_target_variant_matches_canonical_output( + "show-triplets", + &parent_segment_target, + &["--format", "json"], + "absolute parent-segment", + ); +} + +#[test] +fn show_triplets_command_json_absolute_parent_curdir_segment_target_matches_canonical_absolute_output( +) { + let parent_curdir_segment_target = workspace_root() + .join("dsl/tools/./../tools/makegen.dag") + .to_string_lossy() + .into_owned(); + + assert_makegen_target_variant_matches_canonical_output( + "show-triplets", + &parent_curdir_segment_target, + &["--format", "json"], + "absolute parent-curdir-segment", + ); +} + +#[test] +fn show_triplets_command_json_absolute_parent_double_separator_target_matches_canonical_absolute_output( +) { + let parent_double_separator_target = + format!("{}/dsl/..//dsl/tools/makegen.dag", workspace_root().display()); + + assert_makegen_target_variant_matches_canonical_output( + "show-triplets", + &parent_double_separator_target, + &["--format", "json"], + "absolute parent-double-separator", + ); +} + +#[test] +fn show_triplets_command_json_absolute_parent_curdir_double_separator_target_matches_canonical_absolute_output( +) { + let parent_curdir_double_separator_target = + format!("{}/dsl/./tools/..//tools/makegen.dag", workspace_root().display()); + + assert_makegen_target_variant_matches_canonical_output( + "show-triplets", + &parent_curdir_double_separator_target, + &["--format", "json"], + "absolute parent-curdir-double-separator", + ); +} + +#[test] +fn show_triplets_command_json_relative_and_absolute_mixed_case_extension_targets_are_equivalent() { + let root = unique_temp_dir("show_triplets_json_relative_absolute_mixed_case_extension"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let target = root.join("single.DaG"); + std::fs::write( + &target, + r#"module sample.triplets +fn run() -> Unit { } +"#, + ) + .expect("failed to write mixed-case show-triplets fixture"); + + let relative = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("single.DaG") + .arg("--format") + .arg("json") + .current_dir(&root) + .output() + .expect("failed to run relative mixed-case show-triplets json command"); + assert!( + relative.status.success(), + "relative mixed-case show-triplets json command failed: {}", + String::from_utf8_lossy(&relative.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(&target) + .arg("--format") + .arg("json") + .current_dir(&root) + .output() + .expect("failed to run absolute mixed-case show-triplets json command"); + assert!( + absolute.status.success(), + "absolute mixed-case show-triplets json command failed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute mixed-case show-triplets json stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute mixed-case show-triplets json stderr should match" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_relative_and_absolute_uppercase_extension_targets_are_equivalent() { + let root = unique_temp_dir("show_triplets_json_relative_absolute_uppercase_extension"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let target = root.join("single.DAG"); + std::fs::write( + &target, + r#"module sample.triplets +fn run() -> Unit { } +"#, + ) + .expect("failed to write uppercase show-triplets fixture"); + + let relative = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("single.DAG") + .arg("--format") + .arg("json") + .current_dir(&root) + .output() + .expect("failed to run relative uppercase show-triplets json command"); + assert!( + relative.status.success(), + "relative uppercase show-triplets json command failed: {}", + String::from_utf8_lossy(&relative.stderr) + ); + + let absolute = Command::new(daglang_bin()) + .arg("show-triplets") + .arg(&target) + .arg("--format") + .arg("json") + .current_dir(&root) + .output() + .expect("failed to run absolute uppercase show-triplets json command"); + assert!( + absolute.status.success(), + "absolute uppercase show-triplets json command failed: {}", + String::from_utf8_lossy(&absolute.stderr) + ); + + assert_eq!( + relative.stdout, absolute.stdout, + "relative and absolute uppercase show-triplets json stdout should match" + ); + assert_eq!( + relative.stderr, absolute.stderr, + "relative and absolute uppercase show-triplets json stderr should match" + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_uppercase_extension_single_file_target_matches_curdir_suffix_output() { + let root = unique_temp_dir("show_triplets_uppercase_extension_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let target = root.join("single.DAG"); + std::fs::write( + &target, + r#"module sample.triplets +fn run() -> Unit { } +"#, + ) + .expect("failed to write uppercase-extension show-triplets fixture"); + + let plain = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("single.DAG") + .current_dir(&root) + .output() + .expect("failed to run show-triplets on plain uppercase target"); + assert!( + plain.status.success(), + "plain uppercase show-triplets command failed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("./single.DAG") + .current_dir(&root) + .output() + .expect("failed to run show-triplets on curdir-suffix uppercase target"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix uppercase show-triplets command failed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix uppercase show-triplets stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix uppercase show-triplets stderr should match" + ); + assert!( + String::from_utf8_lossy(&plain.stdout).contains("(none)"), + "transport-free uppercase fixture should report no triplets: {}", + String::from_utf8_lossy(&plain.stdout) + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_mixed_case_extension_single_file_target_matches_curdir_suffix_output( +) { + let root = unique_temp_dir("show_triplets_json_mixed_case_extension_single_file"); + std::fs::create_dir_all(&root).expect("failed to create temp root"); + let target = root.join("single.DaG"); + std::fs::write( + &target, + r#"module sample.triplets +fn run() -> Unit { } +"#, + ) + .expect("failed to write mixed-case show-triplets fixture"); + + let plain = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("single.DaG") + .arg("--format") + .arg("json") + .current_dir(&root) + .output() + .expect("failed to run show-triplets --format json on plain mixed-case target"); + assert!( + plain.status.success(), + "plain mixed-case show-triplets json command failed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + + let curdir_suffix = Command::new(daglang_bin()) + .arg("show-triplets") + .arg("./single.DaG") + .arg("--format") + .arg("json") + .current_dir(&root) + .output() + .expect("failed to run show-triplets --format json on curdir-suffix mixed-case target"); + assert!( + curdir_suffix.status.success(), + "curdir-suffix mixed-case show-triplets json command failed: {}", + String::from_utf8_lossy(&curdir_suffix.stderr) + ); + + assert_eq!( + plain.stdout, curdir_suffix.stdout, + "plain and curdir-suffix mixed-case show-triplets json stdout should match" + ); + assert_eq!( + plain.stderr, curdir_suffix.stderr, + "plain and curdir-suffix mixed-case show-triplets json stderr should match" + ); + let parsed: Value = + serde_json::from_slice(&plain.stdout).expect("show-triplets json output should be valid"); + let triplets = parsed + .get("triplets") + .and_then(Value::as_array) + .expect("triplets should be a JSON array"); + assert!(triplets.is_empty(), "transport-free fixture should have no triplets"); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_directory_named_dag_extension_is_invalid_single_file_target() { + let root = unique_temp_dir("show_triplets_directory_named_dag_extension"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + "bundle.dag", + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_directory_named_mixed_case_dag_extension_is_invalid_single_file_target() { + let root = unique_temp_dir("show_triplets_directory_named_mixed_case_dag_extension"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DaG directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + "bundle.DaG", + &dag_dir, + Some("broken.dag:2:3"), + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir("show_triplets_absolute_directory_named_uppercase_dag_extension"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let absolute_input = dag_dir.to_string_lossy().into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &absolute_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_parent_segment_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = + unique_temp_dir("show_triplets_absolute_parent_segment_directory_named_mixed_case_dag_extension"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let parent_segment_input = root + .join("nested/../bundle.DaG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &parent_segment_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_absolute_parent_double_separator_symlink_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_absolute_parent_double_separator_symlink_named_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let root_input = root.to_string_lossy(); + let parent_double_separator_input = format!("{root_input}/nested/..//bundle_link.dag"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &parent_double_separator_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_parent_double_separator_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_absolute_parent_double_separator_directory_named_uppercase_dag_extension", + ); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let root_input = root.to_string_lossy(); + let parent_double_separator_input = format!("{root_input}/nested/..//bundle.DAG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &parent_double_separator_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_absolute_parent_curdir_segment_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_absolute_parent_curdir_segment_symlink_named_uppercase_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let parent_curdir_segment_input = root + .join("nested/./../bundle_link.DAG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &parent_curdir_segment_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_parent_curdir_segment_directory_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_absolute_parent_curdir_segment_directory_named_dag_extension", + ); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + let parent_curdir_segment_input = root + .join("nested/./../bundle.dag") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &parent_curdir_segment_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_parent_curdir_double_separator_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_absolute_parent_curdir_double_separator_directory_named_mixed_case_dag_extension", + ); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let root_input = root.to_string_lossy(); + let parent_curdir_double_separator_input = + format!("{root_input}/nested/./..//bundle.DaG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &parent_curdir_double_separator_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_absolute_parent_curdir_double_separator_symlink_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_absolute_parent_curdir_double_separator_symlink_named_mixed_case_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let root_input = root.to_string_lossy(); + let parent_curdir_double_separator_input = + format!("{root_input}/nested/./..//bundle_link.DaG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &parent_curdir_double_separator_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_parent_curdir_double_separator_directory_alias_matches_canonical_absolute_alias_output( +) { + let root = unique_temp_dir( + "show_triplets_absolute_parent_curdir_double_separator_directory_alias_output_parity", + ); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let canonical_input = dag_dir.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/./..//bundle.DaG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + &canonical_input, + &variant_input, + &[], + "absolute parent-curdir-double-separator directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_parent_curdir_segment_directory_alias_matches_canonical_absolute_alias_output( +) { + let root = unique_temp_dir( + "show_triplets_absolute_parent_curdir_segment_directory_alias_output_parity", + ); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + let canonical_input = dag_dir.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/./../bundle.dag", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + &canonical_input, + &variant_input, + &[], + "absolute parent-curdir-segment directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_parent_double_separator_directory_alias_matches_canonical_absolute_alias_output( +) { + let root = unique_temp_dir( + "show_triplets_absolute_parent_double_separator_directory_alias_output_parity", + ); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let canonical_input = dag_dir.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/..//bundle.DAG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + &canonical_input, + &variant_input, + &[], + "absolute parent-double-separator directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_parent_segment_directory_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("show_triplets_absolute_parent_segment_directory_alias_output_parity"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let canonical_input = dag_dir.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/../bundle.DAG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + &canonical_input, + &variant_input, + &[], + "absolute parent-segment directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_curdir_segment_directory_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("show_triplets_absolute_curdir_segment_directory_alias_output_parity"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + let canonical_input = dag_dir.to_string_lossy().into_owned(); + let variant_input = root.join("./bundle.dag").to_string_lossy().into_owned(); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + &canonical_input, + &variant_input, + &[], + "absolute curdir-segment directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_double_separator_directory_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("show_triplets_absolute_double_separator_directory_alias_output_parity"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let canonical_input = dag_dir.to_string_lossy().into_owned(); + let variant_input = format!("{}//bundle.DaG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + &canonical_input, + &variant_input, + &[], + "absolute double-separator directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_relative_and_absolute_directory_alias_fail_outputs_are_equivalent() { + let root = + unique_temp_dir("show_triplets_relative_absolute_directory_alias_failure_output_parity"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let absolute_input = dag_dir.to_string_lossy().into_owned(); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + "bundle.DaG", + &absolute_input, + &[], + "absolute directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_relative_directory_alias_normalized_spelling_fail_outputs_match_canonical() { + let root = unique_temp_dir( + "show_triplets_relative_directory_alias_normalized_spelling_failure_output_parity", + ); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + assert_single_target_command_failure_outputs_match_for_variants( + "show-triplets", + &root, + "bundle.DaG", + &[ + ("./bundle.DaG", "curdir-suffix alias"), + ("nested/../bundle.DaG", "parent-segment alias"), + ("nested/./../bundle.DaG", "parent-curdir alias"), + ("nested/..//bundle.DaG", "parent-double-separator alias"), + ], + &[], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_relative_and_absolute_symlink_alias_fail_outputs_are_equivalent() { + let root = + unique_temp_dir("show_triplets_relative_absolute_symlink_alias_failure_output_parity"); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let absolute_input = symlink_path.to_string_lossy().into_owned(); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + "bundle_link.dag", + &absolute_input, + &[], + "absolute symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_relative_symlink_alias_normalized_spelling_fail_outputs_match_canonical( +) { + let root = unique_temp_dir( + "show_triplets_json_relative_symlink_alias_normalized_spelling_failure_output_parity", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + assert_single_target_command_failure_outputs_match_for_variants( + "show-triplets", + &root, + "bundle_link.dag", + &[ + ("./bundle_link.dag", "curdir-suffix alias"), + ("nested/../bundle_link.dag", "parent-segment alias"), + ("nested/./../bundle_link.dag", "parent-curdir alias"), + ("nested/..//bundle_link.dag", "parent-double-separator alias"), + ], + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_absolute_symlink_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir("show_triplets_absolute_symlink_named_mixed_case_dag_extension"); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let absolute_input = symlink_path.to_string_lossy().into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &absolute_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_absolute_curdir_segment_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_absolute_curdir_segment_symlink_named_uppercase_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let curdir_segment_input = root + .join("./bundle_link.DAG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &curdir_segment_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_curdir_segment_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_absolute_curdir_segment_directory_named_uppercase_dag_extension", + ); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let curdir_segment_input = root.join("./bundle.DAG").to_string_lossy().into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &curdir_segment_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_absolute_double_separator_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_absolute_double_separator_symlink_named_uppercase_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let root_input = root.to_string_lossy(); + let double_separator_input = format!("{root_input}//bundle_link.DAG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &double_separator_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_absolute_double_separator_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_absolute_double_separator_directory_named_mixed_case_dag_extension", + ); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let root_input = root.to_string_lossy(); + let double_separator_input = format!("{root_input}//bundle.DaG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &double_separator_input, + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_absolute_parent_segment_symlink_named_dag_extension_is_invalid_single_file_target( +) { + let root = + unique_temp_dir("show_triplets_absolute_parent_segment_symlink_named_dag_extension"); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let parent_segment_input = root + .join("nested/../bundle_link.dag") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + &parent_segment_input, + &symlink_path, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_curdir_suffix_directory_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir("show_triplets_curdir_suffix_directory_named_dag_extension"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + "./bundle.dag", + &dag_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_curdir_suffix_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = + unique_temp_dir("show_triplets_curdir_suffix_directory_named_uppercase_dag_extension"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DAG directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + "./bundle.DAG", + &dag_dir, + Some("broken.dag:2:3"), + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_symlink_directory_named_dag_extension_is_invalid_single_file_target() { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("show_triplets_symlink_directory_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory root"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + "link.dag", + &link_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_symlink_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = + unique_temp_dir("show_triplets_symlink_directory_named_uppercase_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DAG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DAG directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + "link.DAG", + &link_dir, + Some("broken.dag:2:3"), + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_curdir_suffix_symlink_named_dag_extension_is_invalid_single_file_target() +{ + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("show_triplets_curdir_suffix_symlink_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory root"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + "./link.dag", + &link_dir, + None, + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_curdir_suffix_symlink_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = + unique_temp_dir("show_triplets_curdir_suffix_symlink_named_mixed_case_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DaG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DaG directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target( + "show-triplets", + &root, + "./link.DaG", + &link_dir, + Some("broken.dag:2:3"), + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_directory_named_dag_extension_is_invalid_single_file_target() { + let root = unique_temp_dir("show_triplets_json_directory_named_dag_extension"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + "bundle.dag", + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir("show_triplets_json_directory_named_uppercase_dag_extension"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DAG directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + "bundle.DAG", + &dag_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir("show_triplets_json_directory_named_mixed_case_dag_extension"); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DaG directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + "bundle.DaG", + &dag_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_absolute_directory_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir("show_triplets_json_absolute_directory_named_dag_extension"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + let absolute_input = dag_dir.to_string_lossy().into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &absolute_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_absolute_curdir_segment_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_curdir_segment_directory_named_mixed_case_dag_extension", + ); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let curdir_segment_input = root.join("./bundle.DaG").to_string_lossy().into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &curdir_segment_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_absolute_parent_segment_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_parent_segment_directory_named_uppercase_dag_extension", + ); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let parent_segment_input = root + .join("nested/../bundle.DAG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &parent_segment_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_absolute_double_separator_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_double_separator_directory_named_mixed_case_dag_extension", + ); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let root_input = root.to_string_lossy(); + let double_separator_input = format!("{root_input}//bundle.DaG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &double_separator_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_curdir_segment_symlink_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_curdir_segment_symlink_named_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let curdir_segment_input = root + .join("./bundle_link.dag") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &curdir_segment_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_double_separator_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_double_separator_symlink_named_uppercase_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let root_input = root.to_string_lossy(); + let double_separator_input = format!("{root_input}//bundle_link.DAG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &double_separator_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_absolute_parent_double_separator_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_parent_double_separator_directory_named_mixed_case_dag_extension", + ); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DaG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DaG directory"); + + let root_input = root.to_string_lossy(); + let parent_double_separator_input = format!("{root_input}/nested/..//bundle.DaG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &parent_double_separator_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_parent_double_separator_symlink_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_parent_double_separator_symlink_named_mixed_case_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let root_input = root.to_string_lossy(); + let parent_double_separator_input = format!("{root_input}/nested/..//bundle_link.DaG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &parent_double_separator_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_absolute_parent_curdir_segment_directory_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_parent_curdir_segment_directory_named_dag_extension", + ); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + let parent_curdir_segment_input = root + .join("nested/./../bundle.dag") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &parent_curdir_segment_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_parent_curdir_segment_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_parent_curdir_segment_symlink_named_uppercase_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let parent_curdir_segment_input = root + .join("nested/./../bundle_link.DAG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &parent_curdir_segment_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_absolute_parent_curdir_double_separator_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_parent_curdir_double_separator_directory_named_uppercase_dag_extension", + ); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let root_input = root.to_string_lossy(); + let parent_curdir_double_separator_input = + format!("{root_input}/nested/./..//bundle.DAG"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &parent_curdir_double_separator_input, + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_parent_curdir_double_separator_symlink_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_parent_curdir_double_separator_symlink_named_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let root_input = root.to_string_lossy(); + let parent_curdir_double_separator_input = + format!("{root_input}/nested/./..//bundle_link.dag"); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &parent_curdir_double_separator_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_parent_curdir_double_separator_symlink_alias_matches_canonical_absolute_alias_output( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_parent_curdir_double_separator_symlink_alias_output_parity", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let canonical_input = symlink_path.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/./..//bundle_link.dag", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + &canonical_input, + &variant_input, + &["--format", "json"], + "absolute parent-curdir-double-separator symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_parent_curdir_segment_symlink_alias_matches_canonical_absolute_alias_output( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_parent_curdir_segment_symlink_alias_output_parity", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let canonical_input = symlink_path.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/./../bundle_link.DAG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + &canonical_input, + &variant_input, + &["--format", "json"], + "absolute parent-curdir-segment symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_parent_double_separator_symlink_alias_matches_canonical_absolute_alias_output( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_parent_double_separator_symlink_alias_output_parity", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let canonical_input = symlink_path.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/..//bundle_link.DaG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + &canonical_input, + &variant_input, + &["--format", "json"], + "absolute parent-double-separator symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_parent_segment_symlink_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("show_triplets_json_absolute_parent_segment_symlink_alias_output_parity"); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let canonical_input = symlink_path.to_string_lossy().into_owned(); + let variant_input = format!("{}/nested/../bundle_link.dag", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + &canonical_input, + &variant_input, + &["--format", "json"], + "absolute parent-segment symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_curdir_segment_symlink_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("show_triplets_json_absolute_curdir_segment_symlink_alias_output_parity"); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let canonical_input = symlink_path.to_string_lossy().into_owned(); + let variant_input = root + .join("./bundle_link.DAG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + &canonical_input, + &variant_input, + &["--format", "json"], + "absolute curdir-segment symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_double_separator_symlink_alias_matches_canonical_absolute_alias_output( +) { + let root = + unique_temp_dir("show_triplets_json_absolute_double_separator_symlink_alias_output_parity"); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DaG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create mixed-case .dag symlink"); + + let canonical_input = symlink_path.to_string_lossy().into_owned(); + let variant_input = format!("{}//bundle_link.DaG", root.display()); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + &canonical_input, + &variant_input, + &["--format", "json"], + "absolute double-separator symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_relative_and_absolute_symlink_alias_fail_outputs_are_equivalent() { + let root = unique_temp_dir( + "show_triplets_json_relative_absolute_symlink_alias_failure_output_parity", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let absolute_input = symlink_path.to_string_lossy().into_owned(); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + "bundle_link.dag", + &absolute_input, + &["--format", "json"], + "absolute symlink alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_relative_and_absolute_directory_alias_fail_outputs_are_equivalent() { + let root = unique_temp_dir( + "show_triplets_json_relative_absolute_directory_alias_failure_output_parity", + ); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .DAG directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .DAG directory"); + + let absolute_input = dag_dir.to_string_lossy().into_owned(); + assert_single_target_command_failure_outputs_match_for_targets( + "show-triplets", + &root, + "bundle.DAG", + &absolute_input, + &["--format", "json"], + "absolute directory alias", + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_parent_segment_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_absolute_parent_segment_symlink_named_uppercase_dag_extension", + ); + let real_dir = root.join("real"); + std::fs::create_dir_all(root.join("nested")).expect("failed to create nested directory"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.DAG"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create uppercase .dag symlink"); + + let parent_segment_input = root + .join("nested/../bundle_link.DAG") + .to_string_lossy() + .into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &parent_segment_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_absolute_symlink_named_dag_extension_is_invalid_single_file_target() { + let root = + unique_temp_dir("show_triplets_json_absolute_symlink_named_dag_extension"); + let real_dir = root.join("real"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + let symlink_path = root.join("bundle_link.dag"); + std::os::unix::fs::symlink(&real_dir, &symlink_path) + .expect("failed to create lowercase .dag symlink"); + + let absolute_input = symlink_path.to_string_lossy().into_owned(); + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + &absolute_input, + &symlink_path, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_curdir_suffix_directory_named_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir("show_triplets_json_curdir_suffix_directory_named_dag_extension"); + let dag_dir = root.join("bundle.dag"); + std::fs::create_dir_all(dag_dir.join("sample")).expect("failed to create .dag directory root"); + std::fs::write( + dag_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in .dag directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + "./bundle.dag", + &dag_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_curdir_suffix_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + let root = + unique_temp_dir("show_triplets_json_curdir_suffix_directory_named_uppercase_dag_extension"); + let dag_dir = root.join("bundle.DAG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DAG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DAG directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + "./bundle.DAG", + &dag_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_symlink_directory_named_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir("show_triplets_json_symlink_directory_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory root"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + "link.dag", + &link_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_curdir_suffix_symlink_named_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = + unique_temp_dir("show_triplets_json_curdir_suffix_symlink_named_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.dag"); + std::fs::create_dir_all(real_dir.join("sample")).expect("failed to create real directory root"); + std::fs::write( + real_dir.join("sample/main.dag"), + "module sample.main\nfn run() -> Unit {}", + ) + .expect("failed to write valid source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .dag directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + "./link.dag", + &link_dir, + None, + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_symlink_directory_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = + unique_temp_dir("show_triplets_json_symlink_directory_named_uppercase_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DAG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DAG directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + "link.DAG", + &link_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_symlink_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = + unique_temp_dir("show_triplets_json_symlink_directory_named_mixed_case_dag_extension"); + let real_dir = root.join("real"); + let link_dir = root.join("link.DaG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DaG directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + "link.DaG", + &link_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[test] +fn show_triplets_command_json_curdir_suffix_directory_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + let root = unique_temp_dir( + "show_triplets_json_curdir_suffix_directory_named_mixed_case_dag_extension", + ); + let dag_dir = root.join("bundle.DaG"); + std::fs::create_dir_all(&dag_dir).expect("failed to create .DaG directory root"); + std::fs::write(dag_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in .DaG directory"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + "./bundle.DaG", + &dag_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_curdir_suffix_symlink_named_mixed_case_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir( + "show_triplets_json_curdir_suffix_symlink_named_mixed_case_dag_extension", + ); + let real_dir = root.join("real"); + let link_dir = root.join("link.DaG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DaG directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + "./link.DaG", + &link_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[cfg(unix)] +#[test] +fn show_triplets_command_json_curdir_suffix_symlink_named_uppercase_dag_extension_is_invalid_single_file_target( +) { + use std::os::unix::fs::symlink; + + let root = unique_temp_dir( + "show_triplets_json_curdir_suffix_symlink_named_uppercase_dag_extension", + ); + let real_dir = root.join("real"); + let link_dir = root.join("link.DAG"); + std::fs::create_dir_all(&real_dir).expect("failed to create real directory root"); + std::fs::write(real_dir.join("broken.dag"), "module sample.broken\nfn") + .expect("failed to write malformed source in real directory"); + symlink(&real_dir, &link_dir).expect("failed to create .DAG directory symlink"); + + assert_single_target_command_treats_dag_directory_as_invalid_single_file_target_with_args( + "show-triplets", + &root, + "./link.DAG", + &link_dir, + Some("broken.dag:2:3"), + &["--format", "json"], + ); + + std::fs::remove_dir_all(root).expect("failed to cleanup temp root"); +} + +#[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"); diff --git a/core/daglang/daglang-derive/src/lib.rs b/core/daglang/daglang-derive/src/lib.rs index fc0db9d0d52..dfe0639a494 100644 --- a/core/daglang/daglang-derive/src/lib.rs +++ b/core/daglang/daglang-derive/src/lib.rs @@ -17,11 +17,10 @@ //! → ToolMetadata //! ``` -use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::collections::{BTreeMap, VecDeque}; -use daglang_lower::LoweredOp; +use daglang_lower::{classify_obligation, LoweredOp, ObligationCategory}; use gunbc_ir::{detect_boundaries, detect_entrypoints, Dag, Node}; -use serde::Serialize; /// Derived artifacts produced from lowered GraphIR. #[derive(Debug, Clone)] @@ -32,69 +31,17 @@ pub struct DerivedArtifacts { } /// Minimal progress manifest for Phase-1 compiler scaffolding. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ProgressManifest { pub total_nodes: usize, pub total_edges: usize, - pub topology: Vec, - pub labels: BTreeMap, - pub subdag_boundaries: Vec, - pub parallel_groups: Vec, - pub scatter_points: Vec, - pub interactive_nodes: Vec, - pub capture_modes: BTreeMap, - pub stage_groups: Vec, - pub resources: BTreeMap>, - // Legacy scaffold fields retained for compatibility. pub waves: Vec>, pub entrypoint_nodes: Vec, pub boundary_nodes: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct TopologyNode { - pub id: String, - pub depth: usize, - pub parent: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct SubDagBoundary { - pub node_id: String, - pub label: String, - pub inner_nodes: Vec, - pub parent: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ParallelGroup { - pub nodes: Vec, - pub depth: usize, - pub parent_subdag: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub enum CaptureMode { - Captured, - Passthrough, - Streamed, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct StageGroup { - pub name: String, - pub node_ids: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ResourceUsage { - pub binding: String, - pub resource: String, - pub mode: String, -} - /// Minimal obligation summary derived from graph structure. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct TestObligations { pub dry_run_completion_required: bool, pub transport_execution_targets: usize, @@ -109,13 +56,13 @@ pub struct TestObligations { } /// Metadata summary for lowered modules. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ToolMetadata { pub modules: Vec, } /// Module-level metadata counts. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ModuleMetadata { pub module: String, pub callable_count: usize, @@ -145,23 +92,10 @@ pub fn derive_artifacts(dag: &Dag) -> Result) -> Result>, DeriveError> Ok(waves) } -fn derive_topology(waves: &[Vec]) -> Vec { - let mut topology = Vec::new(); - for (depth, wave) in waves.iter().enumerate() { - for node_id in wave { - topology.push(TopologyNode { - id: node_id.clone(), - depth, - parent: None, - }); - } - } - topology -} - -fn derive_labels(nodes: &[Node]) -> BTreeMap { - let mut labels = BTreeMap::new(); - for node in nodes { - let label = match node_body_as_opaque(&node.body) { - Some(LoweredOp::Callable { module, name, .. }) => format!("{module}.{name}"), - Some(LoweredOp::Pipeline { module, name, .. }) => format!("{module}.{name}"), - None => node.id.0.clone(), - }; - labels.insert(node.id.0.clone(), label); - } - labels -} - -fn derive_subdag_boundaries(dag: &Dag) -> Vec { - let mut boundaries = Vec::new(); - for node in &dag.nodes { - if let gunbc_ir::node::NodeBody::SubDag(inner) = &node.body { - let mut inner_nodes = inner - .nodes - .iter() - .map(|inner_node| inner_node.id.0.clone()) - .collect::>(); - inner_nodes.sort(); - boundaries.push(SubDagBoundary { - node_id: node.id.0.clone(), - label: node.id.0.clone(), - inner_nodes, - parent: None, - }); - } - } - boundaries.sort_by(|lhs, rhs| lhs.node_id.cmp(&rhs.node_id)); - boundaries -} - -fn derive_parallel_groups(waves: &[Vec]) -> Vec { - let mut groups = Vec::new(); - for (depth, wave) in waves.iter().enumerate() { - if wave.len() < 2 { - continue; - } - groups.push(ParallelGroup { - nodes: wave.clone(), - depth, - parent_subdag: None, - }); - } - groups -} - -fn derive_capture_modes(nodes: &[Node]) -> BTreeMap { - let mut capture_modes = BTreeMap::new(); - for node in nodes { - if is_transport_execution_node(node) { - capture_modes.insert(node.id.0.clone(), CaptureMode::Captured); - } - } - capture_modes -} - -fn derive_resources(nodes: &[Node]) -> BTreeMap> { - let mut resources = BTreeMap::new(); - for node in nodes { - let mut usage_keys = BTreeSet::new(); - for input in &node.inputs { - if let Some(resource) = input.name.0.strip_prefix("res:") { - usage_keys.insert((input.name.0.clone(), resource.to_string(), "ReadWrite".to_string())); - } - } - if !usage_keys.is_empty() { - let usages = usage_keys - .into_iter() - .map(|(binding, resource, mode)| ResourceUsage { - binding, - resource, - mode, - }) - .collect::>(); - resources.insert(node.id.0.clone(), usages); - } - } - resources -} - -fn is_transport_execution_node(node: &Node) -> bool { - if node - .inputs - .iter() - .any(|input| input.type_id.0 == "TransportRequest") - { - return true; - } - matches!( - node_body_as_opaque(&node.body), - Some(LoweredOp::Callable { name, .. }) if name.starts_with("service_transport::execute::") - || name.starts_with("content_upsert::execute_") - ) -} - -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 derive_module_metadata(nodes: &[Node]) -> Vec { let mut by_module: BTreeMap = BTreeMap::new(); for node in nodes { - let Some(op) = node_body_as_opaque(&node.body) else { + let Some(op) = node.body.as_opaque() else { continue; }; match op { @@ -446,49 +260,6 @@ struct ObligationCounts { resource_release_targets: usize, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ObligationNameCategory { - ServiceTransportPrepare, - ServiceTransportExecute, - ServiceTransportParse, - ServiceParamSource, - ResourceProvide, - ResourceAcquire, - ResourceRelease, -} - -fn classify_obligation_name(name: &str) -> Option { - if name.starts_with("service_transport::prepare::") - || name.starts_with("content_upsert::prepare_") - { - return Some(ObligationNameCategory::ServiceTransportPrepare); - } - if name.starts_with("service_transport::execute::") - || name.starts_with("content_upsert::execute_") - { - return Some(ObligationNameCategory::ServiceTransportExecute); - } - if name.starts_with("service_transport::parse::") - || name.starts_with("content_upsert::parse_") - || name.starts_with("content_upsert::compare_") - { - return Some(ObligationNameCategory::ServiceTransportParse); - } - if name.starts_with("call_param_source::") { - return Some(ObligationNameCategory::ServiceParamSource); - } - if name.starts_with("resource_provide::") { - return Some(ObligationNameCategory::ResourceProvide); - } - if name.starts_with("resource_lifecycle::acquire::") { - return Some(ObligationNameCategory::ResourceAcquire); - } - if name.starts_with("resource_lifecycle::release::") { - return Some(ObligationNameCategory::ResourceRelease); - } - None -} - fn derive_obligation_counts(nodes: &[Node]) -> ObligationCounts { let mut counts = ObligationCounts::default(); for node in nodes { @@ -501,41 +272,54 @@ fn derive_obligation_counts(nodes: &[Node]) -> ObligationCounts { } else { counts.pure_node_determinism_targets += 1; } - let Some(LoweredOp::Callable { name, .. }) = node_body_as_opaque(&node.body) else { + let Some(op) = node.body.as_opaque() else { continue; }; - match classify_obligation_name(name) { - Some(ObligationNameCategory::ServiceTransportPrepare) => { + match classify_obligation(op) { + ObligationCategory::ServiceTransportPrepare => { counts.service_transport_prepare_targets += 1; } - Some(ObligationNameCategory::ServiceTransportExecute) => { + ObligationCategory::ServiceTransportExecute => { counts.service_transport_execute_targets += 1; } - Some(ObligationNameCategory::ServiceTransportParse) => { + ObligationCategory::ServiceTransportParse => { counts.service_transport_parse_targets += 1; } - Some(ObligationNameCategory::ServiceParamSource) => { + ObligationCategory::ServiceParamSource => { counts.service_param_source_targets += 1; } - Some(ObligationNameCategory::ResourceProvide) => { + ObligationCategory::ResourceProvide => { counts.resource_provide_targets += 1; } - Some(ObligationNameCategory::ResourceAcquire) => { + ObligationCategory::ResourceAcquire => { counts.resource_acquire_targets += 1; } - Some(ObligationNameCategory::ResourceRelease) => { + ObligationCategory::ResourceRelease => { counts.resource_release_targets += 1; } - None => {} + ObligationCategory::None => {} } } 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 daglang_lower::{CallableKind, LoweredOp, ObligationCategory}; use gunbc_ir::{Edge, Node, Port}; fn callable_node(id: &str, module: &str, name: &str) -> Node { @@ -547,6 +331,7 @@ mod tests { module: module.to_string(), kind: CallableKind::Func, name: name.to_string(), + obligation: ObligationCategory::None, }, ) } @@ -570,16 +355,6 @@ mod tests { ); assert_eq!(artifacts.manifest.total_nodes, 3); assert_eq!(artifacts.manifest.total_edges, 2); - assert_eq!(artifacts.manifest.topology.len(), 3); - assert_eq!(artifacts.manifest.parallel_groups.len(), 1); - assert_eq!( - artifacts - .manifest - .labels - .get("a") - .expect("label for node a should exist"), - "tools.makegen.a" - ); } #[test] @@ -621,6 +396,7 @@ mod tests { module: "sample.services".to_string(), kind: CallableKind::Pattern, name: "call_param_source::run::path".to_string(), + obligation: ObligationCategory::ServiceParamSource, }, )); dag.add_node(Node::opaque( @@ -631,6 +407,7 @@ mod tests { module: "sample.services".to_string(), kind: CallableKind::Pattern, name: "service_transport::prepare::FsStorage::read".to_string(), + obligation: ObligationCategory::ServiceTransportPrepare, }, )); dag.add_node(Node::opaque( @@ -641,6 +418,7 @@ mod tests { module: "sample.services".to_string(), kind: CallableKind::Pattern, name: "service_transport::execute::FsStorage::read".to_string(), + obligation: ObligationCategory::ServiceTransportExecute, }, )); dag.add_node(Node::opaque( @@ -651,6 +429,7 @@ mod tests { module: "sample.services".to_string(), kind: CallableKind::Pattern, name: "service_transport::parse::FsStorage::read".to_string(), + obligation: ObligationCategory::ServiceTransportParse, }, )); dag.add_node(Node::opaque( @@ -661,6 +440,7 @@ mod tests { module: "sample.resources".to_string(), kind: CallableKind::Pattern, name: "resource_provide::run::out".to_string(), + obligation: ObligationCategory::ResourceProvide, }, )); dag.add_node(Node::opaque( @@ -671,6 +451,7 @@ mod tests { module: "sample.resources".to_string(), kind: CallableKind::Pattern, name: "resource_lifecycle::acquire::TempFile".to_string(), + obligation: ObligationCategory::ResourceAcquire, }, )); dag.add_node(Node::opaque( @@ -681,6 +462,7 @@ mod tests { module: "sample.resources".to_string(), kind: CallableKind::Pattern, name: "resource_lifecycle::release::TempFile".to_string(), + obligation: ObligationCategory::ResourceRelease, }, )); dag.add_edge(Edge::new( @@ -727,158 +509,36 @@ mod tests { } #[test] - fn derive_obligations_counts_content_upsert_triplets_as_transport_targets() { - let mut dag = Dag::new(); - dag.add_node(Node::opaque( - "prepare_read_makegen", - vec![Port::scalar("path", "String")], - vec![Port::scalar("request", "TransportRequest")], - LoweredOp::Callable { - module: "tools.makegen".to_string(), - kind: CallableKind::Pattern, - name: "content_upsert::prepare_read_makegen".to_string(), - }, - )); - dag.add_node(Node::opaque( - "execute_read_makegen", - vec![Port::scalar("request", "TransportRequest")], - vec![Port::scalar("response", "TransportResponse")], - LoweredOp::Callable { - module: "tools.makegen".to_string(), - kind: CallableKind::Pattern, - name: "content_upsert::execute_read_makegen".to_string(), - }, - )); - dag.add_node(Node::opaque( - "compare_makegen_content", - vec![Port::scalar("response", "TransportResponse")], - vec![Port::scalar("skip", "Bool")], - LoweredOp::Callable { - module: "tools.makegen".to_string(), - kind: CallableKind::Pattern, - name: "content_upsert::compare_makegen_content".to_string(), - }, - )); - 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", - )); - - 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, 2); - 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); - } - - #[test] - fn derive_manifest_contract_fields_include_topology_capture_and_resources() { + fn derive_obligations_uses_structural_category_not_callable_name_prefix() { let mut dag = Dag::new(); dag.add_node(Node::opaque( - "root_a", - vec![], - vec![Port::scalar("path", "String")], - LoweredOp::Callable { - module: "sample.pipeline".to_string(), - kind: CallableKind::Fn, - name: "root_a".to_string(), - }, - )); - dag.add_node(Node::opaque( - "root_b", + "misleading_name", vec![], - vec![Port::scalar("content", "String")], + vec![Port::scalar("out", "String")], LoweredOp::Callable { - module: "sample.pipeline".to_string(), - kind: CallableKind::Fn, - name: "root_b".to_string(), - }, - )); - dag.add_node(Node::opaque( - "prepare_write", - vec![ - Port::scalar("path", "String"), - Port::scalar("content", "String"), - Port::scalar("res:file:Makefile", "FilesystemHandle"), - ], - vec![Port::scalar("request", "TransportRequest")], - LoweredOp::Callable { - module: "sample.pipeline".to_string(), + module: "sample.services".to_string(), kind: CallableKind::Pattern, - name: "content_upsert::prepare_write".to_string(), + name: "service_transport::prepare::Fake::op".to_string(), + obligation: ObligationCategory::None, }, )); dag.add_node(Node::opaque( - "execute_write", - vec![Port::scalar("request", "TransportRequest")], - vec![Port::scalar("response", "TransportResponse")], + "structural_category", + vec![], + vec![Port::scalar("out", "String")], LoweredOp::Callable { - module: "sample.pipeline".to_string(), + module: "sample.services".to_string(), kind: CallableKind::Pattern, - name: "content_upsert::execute_write".to_string(), + name: "not_a_transport_name".to_string(), + obligation: ObligationCategory::ServiceTransportPrepare, }, )); - dag.add_edge(Edge::new("root_a", "path", "prepare_write", "path")); - dag.add_edge(Edge::new("root_b", "content", "prepare_write", "content")); - dag.add_edge(Edge::new("prepare_write", "request", "execute_write", "request")); let artifacts = derive_artifacts(&dag).expect("derivation should succeed"); - assert_eq!(artifacts.manifest.topology.len(), 4); - assert_eq!( - artifacts - .manifest - .topology - .iter() - .filter(|node| node.depth == 0) - .count(), - 2, - "two roots should occupy depth 0" - ); - assert!( - artifacts - .manifest - .parallel_groups - .iter() - .any(|group| group.depth == 0 - && group.nodes - == vec!["root_a".to_string(), "root_b".to_string()]), - "root wave should form a parallel group" - ); - assert_eq!( - artifacts - .manifest - .labels - .get("execute_write") - .expect("execute label should exist"), - "sample.pipeline.content_upsert::execute_write" - ); assert_eq!( - artifacts - .manifest - .capture_modes - .get("execute_write") - .copied(), - Some(CaptureMode::Captured), - "transport execute node should be marked captured" + artifacts.obligations.service_transport_prepare_targets, + 1, + "classification should follow structural obligation category" ); - let resources = artifacts - .manifest - .resources - .get("prepare_write") - .expect("prepare_write should include resource usage"); - assert_eq!(resources.len(), 1); - assert_eq!(resources[0].resource, "file:Makefile"); - assert!(artifacts.manifest.subdag_boundaries.is_empty()); - assert!(artifacts.manifest.interactive_nodes.is_empty()); - assert!(artifacts.manifest.stage_groups.is_empty()); } } diff --git a/core/daglang/daglang-emit/src/lib.rs b/core/daglang/daglang-emit/src/lib.rs index 1b5e190c387..69a21ee1e78 100644 --- a/core/daglang/daglang-emit/src/lib.rs +++ b/core/daglang/daglang-emit/src/lib.rs @@ -163,7 +163,12 @@ pub fn emit_rust_bundle( }; match op { - LoweredOp::Callable { module, kind, name } => { + LoweredOp::Callable { + module, + kind, + name, + .. + } => { callable_count += 1; let fn_name = sanitize_identifier(&format!("{module}_{name}")); let rendered = match kind { @@ -270,6 +275,7 @@ impl NodeBodyExt for gunbc_ir::node::NodeBody { mod tests { use super::*; use daglang_derive::derive_artifacts; + use daglang_lower::ObligationCategory; use gunbc_ir::{Edge, Node, Port}; fn sample_dag() -> Dag { @@ -282,6 +288,7 @@ mod tests { module: "tools.makegen".to_string(), kind: CallableKind::Fn, name: "render_makefile".to_string(), + obligation: ObligationCategory::None, }, )); dag.add_node(Node::opaque( @@ -292,6 +299,7 @@ mod tests { module: "tools.makegen".to_string(), kind: CallableKind::Func, name: "makegen".to_string(), + obligation: ObligationCategory::None, }, )); dag.add_edge(Edge::new( diff --git a/core/daglang/daglang-lower/src/lib.rs b/core/daglang/daglang-lower/src/lib.rs index effcc1bd095..7362ea0baca 100644 --- a/core/daglang/daglang-lower/src/lib.rs +++ b/core/daglang/daglang-lower/src/lib.rs @@ -34,6 +34,7 @@ pub enum LoweredOp { module: String, kind: CallableKind, name: String, + obligation: ObligationCategory, }, Pipeline { module: String, @@ -116,6 +117,31 @@ pub enum CallableKind { Pattern, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObligationCategory { + None, + ServiceTransportPrepare, + ServiceTransportExecute, + ServiceTransportParse, + ServiceParamSource, + ResourceProvide, + ResourceAcquire, + ResourceRelease, +} + +impl LoweredOp { + pub fn obligation_category(&self) -> ObligationCategory { + match self { + Self::Callable { obligation, .. } => *obligation, + Self::Pipeline { .. } => ObligationCategory::None, + } + } +} + +pub fn classify_obligation(op: &LoweredOp) -> ObligationCategory { + op.obligation_category() +} + #[derive(Debug, Clone, PartialEq, Eq)] struct LoweredEndpoint { node_id: String, @@ -821,6 +847,7 @@ fn lower_callable( module: module_name.to_string(), kind, name: callable.name.clone(), + obligation: ObligationCategory::None, }, ), LoweredEndpoint { @@ -1000,6 +1027,7 @@ fn expand_single_content_upsert( module: module_name.to_string(), kind: CallableKind::Pattern, name: format!("content_upsert::{prepare_read_id}"), + obligation: ObligationCategory::None, }, )); builder.add_node(Node::opaque( @@ -1010,6 +1038,7 @@ fn expand_single_content_upsert( module: module_name.to_string(), kind: CallableKind::Pattern, name: format!("content_upsert::{execute_read_id}"), + obligation: ObligationCategory::None, }, )); builder.add_node(Node::opaque( @@ -1026,6 +1055,7 @@ fn expand_single_content_upsert( module: module_name.to_string(), kind: CallableKind::Pattern, name: format!("content_upsert::{compare_id}"), + obligation: ObligationCategory::None, }, )); builder.add_node(Node::opaque( @@ -1039,6 +1069,7 @@ fn expand_single_content_upsert( module: module_name.to_string(), kind: CallableKind::Pattern, name: format!("content_upsert::{prepare_write_id}"), + obligation: ObligationCategory::None, }, )); builder.add_node(Node::opaque( @@ -1052,6 +1083,7 @@ fn expand_single_content_upsert( module: module_name.to_string(), kind: CallableKind::Pattern, name: format!("content_upsert::{execute_transport_id}"), + obligation: ObligationCategory::None, }, )); @@ -1164,6 +1196,7 @@ fn add_makegen_scaffolding( module: "tools.makegen".to_string(), kind: CallableKind::Pattern, name: "load_registry".to_string(), + obligation: ObligationCategory::None, }, )); } @@ -1192,6 +1225,7 @@ fn add_makegen_scaffolding( module: "tools.makegen".to_string(), kind: CallableKind::Pattern, name: "fs_env".to_string(), + obligation: ObligationCategory::None, }, )); } @@ -1250,6 +1284,7 @@ fn add_service_transport_triplets( "service_transport::prepare::{}::{}", service.name, operation.name ), + obligation: ObligationCategory::ServiceTransportPrepare, }, )); builder.add_node(Node::opaque( @@ -1263,6 +1298,7 @@ fn add_service_transport_triplets( "service_transport::execute::{}::{}", service.name, operation.name ), + obligation: ObligationCategory::ServiceTransportExecute, }, )); let parse_outputs = if operation.outputs.is_empty() { @@ -1292,6 +1328,7 @@ fn add_service_transport_triplets( "service_transport::parse::{}::{}", service.name, operation.name ), + obligation: ObligationCategory::ServiceTransportParse, }, )); @@ -1561,6 +1598,7 @@ fn add_provided_resource_nodes( "resource_provide::{}::{}", item_name, provided.binding ), + obligation: ObligationCategory::ResourceProvide, }, )); builder.add_edge( @@ -1659,6 +1697,7 @@ fn add_resource_lifecycle_nodes( module: module_name.clone(), kind: CallableKind::Pattern, name: format!("resource_lifecycle::acquire::{}", resource.name), + obligation: ObligationCategory::ResourceAcquire, }, )); has_acquire = true; @@ -1672,6 +1711,7 @@ fn add_resource_lifecycle_nodes( module: module_name.clone(), kind: CallableKind::Pattern, name: format!("resource_lifecycle::release::{}", resource.name), + obligation: ObligationCategory::ResourceRelease, }, )); has_release = true; @@ -1730,6 +1770,7 @@ fn ensure_param_source_node( module: module_name.to_string(), kind: CallableKind::Pattern, name: format!("call_param_source::{callable}::{param}"), + obligation: ObligationCategory::ServiceParamSource, }, )); node_id @@ -2356,6 +2397,27 @@ func run() -> { ok: Bool } provides out: Storage { assert!(matches!(error, LowerError::NoLowerableItems)); } + #[test] + fn classify_obligation_uses_structural_lowered_metadata() { + let op = LoweredOp::Callable { + module: "sample.services".to_string(), + kind: CallableKind::Pattern, + name: "prepare_transport_sample".to_string(), + obligation: ObligationCategory::ServiceTransportPrepare, + }; + assert_eq!( + classify_obligation(&op), + ObligationCategory::ServiceTransportPrepare + ); + + let pipeline = LoweredOp::Pipeline { + module: "pipelines.ci".to_string(), + name: "ci".to_string(), + stages: 4, + }; + assert_eq!(classify_obligation(&pipeline), ObligationCategory::None); + } + // Test infrastructure: filesystem access for test fixtures #[allow(clippy::disallowed_methods)] #[test] @@ -2420,6 +2482,7 @@ func run() -> { ok: Bool } provides out: Storage { module: "tools.makegen".to_string(), kind: CallableKind::Fn, name: "render".to_string(), + obligation: ObligationCategory::None, }, )); let mut reference = Dag::new(); @@ -2453,6 +2516,7 @@ func run() -> { ok: Bool } provides out: Storage { module: "sample".to_string(), kind: CallableKind::Fn, name: "b".to_string(), + obligation: ObligationCategory::None, }, )); candidate.add_node(Node::opaque( @@ -2463,6 +2527,7 @@ func run() -> { ok: Bool } provides out: Storage { module: "sample".to_string(), kind: CallableKind::Fn, name: "a".to_string(), + obligation: ObligationCategory::None, }, )); candidate.add_edge(Edge::new("a", "out", "b", "in")); diff --git a/docs/design/v4/dsl-roadmap.md b/docs/design/v4/dsl-roadmap.md index d17a4fcf17c..4c62506456e 100644 --- a/docs/design/v4/dsl-roadmap.md +++ b/docs/design/v4/dsl-roadmap.md @@ -371,7 +371,7 @@ The bridge is complete when: - [ ] `dag manifest` emits the **contract ProgressManifest** (JSON) - [ ] IR snapshot tests exist for `tools/makegen.dag` - [ ] Parity harness can compare compiled makegen vs builder makegen and report diffs -- [ ] `dag obligations` and `dag show-triplets` exist (even if obligations are initially "best effort") +- [x] `dag obligations` and `dag show-triplets` exist (even if obligations are initially "best effort") This set unblocks Phase 1 without risking a giant "run makegen end-to-end" rewrite before the compiler outputs are stable. @@ -507,12 +507,12 @@ The codebase has several helpers that call `std::env::current_dir()` internally, The pattern: every function in the `daglang-cli` crate below `main()` should be a pure function of its arguments. I/O happens exactly at the boundary — `main()` reads the environment, commands write to stdout/stderr. **Acceptance criteria**: -- [ ] `path_utils::resolve_default_root(cwd: &Path)` — no `current_dir()` call -- [ ] `path_utils::normalize_cli_path(cwd: &Path, path: &Path)` — no `current_dir()` call -- [ ] `main.rs` calls `current_dir()` exactly once, threads `cwd` to all subcommands -- [ ] `build_context()` takes `cwd: &Path`, not implicitly reading environment -- [ ] No function in `path_utils.rs`, `compile.rs`, or `pipeline.rs` calls `std::env::current_dir()` -- [ ] All existing tests pass (behavior is identical, only the call site of `current_dir()` moved) +- [x] `path_utils::resolve_default_root(cwd: &Path)` — no `current_dir()` call +- [x] `path_utils::normalize_cli_path(cwd: &Path, path: &Path)` — no `current_dir()` call +- [x] `main.rs` calls `current_dir()` exactly once, threads `cwd` to all subcommands +- [x] `build_context()` takes `cwd: &Path`, not implicitly reading environment +- [x] No function in `path_utils.rs`, `compile.rs`, or `pipeline.rs` calls `std::env::current_dir()` +- [x] All existing tests pass (behavior is identical, only the call site of `current_dir()` moved) ##### Step 2: Typed Pipeline Runner @@ -539,12 +539,12 @@ fn run_pipeline(context: &PipelineContext, stop: PipelineStop) -> Result` -- [ ] `PipelineResult` is a typed enum (not string-keyed values) -- [ ] The 5 `take_*` functions are deleted -- [ ] `build_pipeline_dag()` still exists (for `dag viz --self`) -- [ ] `dag check`, `dag modules`, and `dag compile` produce identical output -- [ ] All existing pipeline tests pass +- [x] `run_pipeline()` is an imperative function with typed locals — no `HashMap` +- [x] `PipelineResult` is a typed enum (no string-keyed values) +- [x] The 5 `take_*` functions are deleted +- [x] `build_pipeline_dag()` still exists (for `dag viz --self`) +- [x] `dag check`, `dag modules`, and `dag compile` produce identical output +- [x] All existing pipeline tests pass ##### Step 3: Obligation Classification + CLI Commands @@ -573,13 +573,14 @@ While touching the CLI, add two standalone commands that are cheap to implement - `dag obligations ` — print the 4-bucket test obligation summary (currently embedded in `dag manifest` output, extract to standalone) **Acceptance criteria**: -- [ ] `ObligationCategory` enum exists with typed variants -- [ ] `classify_obligation()` is a pure function on `LoweredOp` — no string prefix matching -- [ ] `derive_test_obligations()` uses `classify_obligation()` internally -- [ ] Obligation counts for `tools/makegen.dag` are unchanged (behavioral parity) -- [ ] `dag show-triplets tools/makegen.dag` shows the content_upsert triplet expansion -- [ ] `dag obligations tools/makegen.dag` shows the 4-bucket obligation summary -- [ ] Both new commands support `--format json` +- [x] `ObligationCategory` enum exists with typed variants +- [x] `classify_obligation()` is a pure function on `LoweredOp` — no string prefix matching +- [x] `derive_test_obligations()` uses `classify_obligation()` internally +- [x] Obligation counts for `tools/makegen.dag` are unchanged (behavioral parity) +- [x] `dag show-triplets tools/makegen.dag` shows the content_upsert triplet expansion +- [x] `dag obligations tools/makegen.dag` shows the 4-bucket obligation summary +- [x] Both new commands support `--format json` +- [x] New command path handling is hardened with regression coverage (relative/absolute targets, normalized absolute path spellings including parent/curdir/double-separator variants and combined parent+curdir+double-separator forms, case-variant `.dag` files, and `.dag`-suffixed directory/symlink aliases—including absolute alias paths across text/json and directory/symlink permutations, plus canonical-vs-normalized and relative-vs-absolute failing-output parity checks for invalid alias spellings, including normalized relative variants) ##### Worker 2 Definition of Done @@ -1658,7 +1659,7 @@ The effort lands cleanly when all six criteria are met: - [x] `core/daglang/` workspace area with crate split: `daglang-syntax`, `daglang-resolve`, `daglang-typecheck`, `daglang-lower`, `daglang-derive`, `daglang-emit`, `daglang-cli` - [x] Compiler entrypoint: `compile_from_context()` does discover → typecheck → lower → derive → emit -- [x] CLI commands: `viz`, `expand`, `manifest`, `modules`, `check`, `compile` +- [x] CLI commands: `viz`, `expand`, `manifest`, `obligations`, `show-triplets`, `modules`, `check`, `compile` - [x] Module graph: filesystem discovery, import resolution, dependency-ordered module listing - [x] Parity harness (partial): `compare_topology()` + `compare_makegen_topology()` returning `ParityReport` - [x] DSL corpus: `.dag` files for tools, services, pipelines, infra, cloud, examples @@ -1672,7 +1673,7 @@ The effort lands cleanly when all six criteria are met: - [ ] **Workstream A (Manifest contract):** Expand `ProgressManifest` to match the roadmap contract; add `dag manifest --format json` - [ ] **Workstream C (Parity + snapshots):** Canonical JSON serialization + IR snapshot tests for at least `tools/makegen.dag` - [ ] **Workstream B (Viz + expand):** ASCII default for `dag viz`; golden tests for `dag expand` -- [ ] **Workstream E (Model preview commands):** `dag show-triplets` and `dag obligations` as standalone commands +- [x] **Workstream E (Model preview commands):** `dag show-triplets` and `dag obligations` as standalone commands - [ ] **Workstream F (Makegen parity):** Compile `tools/makegen.dag` → compare against `build_makegen_graph()` using parity harness - [ ] **Workstream D (Discovery):** Config-driven roots; enriched `dag modules` output