use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use crate::diagnostics::ValidationReport;
use crate::error::Error;
use crate::parser;
use crate::plan;
use crate::{validate, VERSION};
pub const EXIT_OK: u8 = 0;
pub const EXIT_VALIDATION: u8 = 1;
pub const EXIT_FAILURE: u8 = 2;
#[derive(Debug, Parser)]
#[command(
name = "dpcs",
version = VERSION,
about = "Data Pipeline Contract Standard (DPCS) toolkit",
long_about = "Parse, inspect, and validate DPCS Pipeline Contracts.\n\nSPEC.md is the authoritative source of truth for DPCS semantics."
)]
pub struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Validate {
path: PathBuf,
#[arg(long)]
json: bool,
#[arg(long)]
strict: bool,
},
Inspect {
path: PathBuf,
#[arg(long)]
json: bool,
},
Diagnostics {
path: PathBuf,
#[arg(long)]
json: bool,
},
Graph {
path: PathBuf,
#[arg(long)]
json: bool,
},
Version,
}
pub fn run() -> ExitCode {
let cli = Cli::parse();
match execute(cli) {
Ok(code) => ExitCode::from(code),
Err(err) => handle_cli_error(err, false),
}
}
fn handle_cli_error(err: Error, json: bool) -> ExitCode {
if let Some(report) = err.invalid_document_report() {
if let Err(print_err) = print_report(report, json) {
eprintln!("error: {print_err}");
return ExitCode::from(EXIT_FAILURE);
}
return ExitCode::from(EXIT_FAILURE);
}
eprintln!("error: {err}");
ExitCode::from(EXIT_FAILURE)
}
fn execute(cli: Cli) -> Result<u8, Error> {
match cli.command {
Commands::Version => {
println!("dpcs {VERSION}");
Ok(EXIT_OK)
}
Commands::Validate { path, json, strict } => {
let contract = match parser::parse_file(&path) {
Ok(contract) => contract,
Err(Error::InvalidDocument { report }) => {
print_report(&report, json)?;
return Ok(EXIT_FAILURE);
}
Err(err) => return Err(err),
};
let report = validate(&contract);
print_report(&report, json)?;
Ok(exit_code_for_report(&report, strict))
}
Commands::Inspect { path, json } => {
let contract = match parser::parse_file(&path) {
Ok(contract) => contract,
Err(Error::InvalidDocument { report }) => {
print_report(&report, json)?;
return Ok(EXIT_FAILURE);
}
Err(err) => return Err(err),
};
let summary = InspectSummary {
id: contract.id.clone(),
name: contract.name.clone(),
version: contract.version.clone(),
dpcs_version: contract.dpcs_version.clone(),
step_count: contract.steps.len(),
edge_count: contract.graph.edges.len(),
input_count: contract.interface.inputs.len(),
output_count: contract.interface.outputs.len(),
contract_reference_count: contract.contract_references.len(),
data_flow_count: contract.data_flow.len(),
control_flow_count: contract.control_flow.len(),
valid: contract.validate().is_valid(),
};
if json {
let payload = serde_json::to_string_pretty(&summary).map_err(|err| {
Error::Serialization(format!("failed to serialize inspect summary: {err}"))
})?;
println!("{payload}");
} else {
println!("id: {}", summary.id);
if let Some(name) = &summary.name {
println!("name: {name}");
}
println!("version: {}", summary.version);
println!("dpcsVersion: {}", summary.dpcs_version);
println!("steps: {}", summary.step_count);
println!("edges: {}", summary.edge_count);
println!("inputs: {}", summary.input_count);
println!("outputs: {}", summary.output_count);
println!("contractReferences: {}", summary.contract_reference_count);
println!("dataFlow: {}", summary.data_flow_count);
println!("controlFlow: {}", summary.control_flow_count);
println!("valid: {}", summary.valid);
}
Ok(EXIT_OK)
}
Commands::Diagnostics { path, json } => {
let contract = match parser::parse_file(&path) {
Ok(contract) => contract,
Err(Error::InvalidDocument { report }) => {
print_report(&report, json)?;
return Ok(EXIT_FAILURE);
}
Err(err) => return Err(err),
};
let report = validate(&contract);
print_report(&report, json)?;
Ok(exit_code_for_report(&report, false))
}
Commands::Graph { path, json } => {
let contract = match parser::parse_file(&path) {
Ok(contract) => contract,
Err(Error::InvalidDocument { report }) => {
print_report(&report, json)?;
return Ok(EXIT_FAILURE);
}
Err(err) => return Err(err),
};
let plan = plan::plan(&contract);
if json {
let payload = GraphPayload {
entry_points: contract.graph.entry_points.clone(),
exit_points: contract.graph.exit_points.clone(),
edges: contract
.graph
.edges
.iter()
.map(|e| GraphEdgeView {
from: e.from.clone(),
to: e.to.clone(),
kind: e.kind.clone(),
})
.collect(),
step_order: plan.step_order,
};
let payload = serde_json::to_string_pretty(&payload).map_err(|err| {
Error::Serialization(format!("failed to serialize graph payload: {err}"))
})?;
println!("{payload}");
} else {
if !contract.graph.entry_points.is_empty() {
println!("entryPoints: {}", contract.graph.entry_points.join(", "));
}
if !contract.graph.exit_points.is_empty() {
println!("exitPoints: {}", contract.graph.exit_points.join(", "));
}
if contract.graph.edges.is_empty() {
println!("(no edges)");
} else {
for edge in &contract.graph.edges {
match &edge.kind {
Some(kind) => println!("{} -> {} ({kind})", edge.from, edge.to),
None => println!("{} -> {}", edge.from, edge.to),
}
}
}
if !plan.step_order.is_empty() {
println!("stepOrder: {}", plan.step_order.join(", "));
}
}
Ok(EXIT_OK)
}
}
}
fn print_report(report: &ValidationReport, json: bool) -> Result<(), Error> {
if json {
let payload = serde_json::to_string_pretty(report).map_err(|err| {
Error::Serialization(format!("failed to serialize diagnostics: {err}"))
})?;
println!("{payload}");
return Ok(());
}
if report.diagnostics.is_empty() {
println!("valid: no diagnostics");
return Ok(());
}
for diagnostic in &report.diagnostics {
let object = diagnostic
.object_ref
.as_deref()
.map(|value| format!(" @ {value}"))
.unwrap_or_default();
let location = diagnostic
.source_location
.as_deref()
.map(|value| format!(" ({value})"))
.unwrap_or_default();
println!(
"{} {}: {}{} — {}{}",
diagnostic.severity,
diagnostic.id,
diagnostic.stage,
object,
diagnostic.message,
location
);
if let Some(remediation) = &diagnostic.remediation {
println!(" remediation: {remediation}");
}
}
println!(
"summary: {} error(s), {} warning(s)",
report.error_count(),
report.warning_count()
);
Ok(())
}
fn exit_code_for_report(report: &ValidationReport, strict: bool) -> u8 {
if report.error_count() > 0 {
return EXIT_VALIDATION;
}
if strict && report.warning_count() > 0 {
return EXIT_VALIDATION;
}
EXIT_OK
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct InspectSummary {
id: String,
name: Option<String>,
version: String,
dpcs_version: String,
step_count: usize,
edge_count: usize,
input_count: usize,
output_count: usize,
contract_reference_count: usize,
data_flow_count: usize,
control_flow_count: usize,
valid: bool,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct GraphPayload {
#[serde(skip_serializing_if = "Vec::is_empty")]
entry_points: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
exit_points: Vec<String>,
edges: Vec<GraphEdgeView>,
step_order: Vec<String>,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct GraphEdgeView {
from: String,
to: String,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<String>,
}