use crate::cli::PlanFormat;
use crate::collections::{find_collection, parse_collection};
use crate::core::parser::{parse_spec, ParameterLocation, UnifiedOperation, UnifiedSpec};
use anyhow::{Context, Result};
use colored::*;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, serde::Serialize)]
pub struct PlanStep {
pub step: usize,
pub operation_id: String,
pub method: String,
pub path: String,
pub summary: Option<String>,
pub risk: RiskLabel,
pub auth: AuthLabel,
pub path_params: Vec<String>,
pub requires_body: bool,
pub depends_on: Vec<String>,
pub critical: bool,
pub readiness: StepReadiness,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct RiskLabel {
pub level: String,
pub display: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct AuthLabel {
pub required: bool,
pub scheme: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StepReadiness {
pub status: String, pub gaps: Vec<StepGap>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StepGap {
pub kind: String,
pub detail: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PlanSketchOutput {
pub sketch_id: String,
pub steps: Vec<PlanStep>,
pub total_steps: usize,
pub plan_risk: String,
pub risk_summary: RiskSummary,
#[serde(skip_serializing_if = "Option::is_none")]
pub readiness_summary: Option<ReadinessSummary>,
pub note: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct RiskSummary {
pub read: usize,
pub write: usize,
pub destructive: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ReadinessSummary {
pub ready: usize,
pub needs_input: usize,
pub unresolved: usize,
}
pub fn sketch_from_operations(
operation_ids: &[String],
spec_path: Option<&PathBuf>,
) -> Result<PlanSketchOutput> {
let spec = load_spec(spec_path.map(|p| p.as_path()))?;
let mut steps = Vec::new();
for (i, op_id) in operation_ids.iter().enumerate() {
let operation = find_operation(&spec, op_id)?;
steps.push(build_step(i + 1, operation, &spec, &[], false));
}
Ok(build_output(steps))
}
pub fn sketch_from_collection(
name: &str,
dir: &Path,
spec_path: Option<&PathBuf>,
) -> Result<PlanSketchOutput> {
let collection_path = find_collection(dir, name)?;
let collection = parse_collection(&collection_path)?;
let spec = load_spec(spec_path.map(|p| p.as_path()))?;
let mut steps = Vec::new();
for (i, req) in collection.requests.iter().enumerate() {
match find_operation(&spec, &req.operation) {
Ok(operation) => {
let deps = req.depends_on.as_ref().cloned().unwrap_or_default();
steps.push(build_step(i + 1, operation, &spec, &deps, req.critical));
}
Err(_) => {
steps.push(unresolved_step(i + 1, req));
}
}
}
Ok(build_output(steps))
}
pub fn display_sketch(sketch: &PlanSketchOutput, format: &PlanFormat, show_gaps: bool) {
match format {
PlanFormat::Text => display_text(sketch, show_gaps),
PlanFormat::Json => display_json(sketch, show_gaps),
}
}
fn load_spec(spec_path: Option<&Path>) -> Result<UnifiedSpec> {
let path = match spec_path {
Some(p) => p.to_path_buf(),
None => auto_find_spec()?,
};
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read spec: {}", path.display()))?;
parse_spec(&content).with_context(|| "Failed to parse OpenAPI spec")
}
fn auto_find_spec() -> Result<PathBuf> {
let current_dir = std::env::current_dir()?;
let candidates = [
"openapi.yaml",
"openapi.yml",
"openapi.json",
"swagger.yaml",
"swagger.yml",
"swagger.json",
"api.yaml",
"api.yml",
"api.json",
"spec.yaml",
"spec.yml",
"spec.json",
];
let search_dirs = [
current_dir.join(".mrapids"),
current_dir.join("specs"),
current_dir,
];
search_dirs
.iter()
.filter(|d| d.exists())
.flat_map(|d| candidates.iter().map(move |c| d.join(c)))
.find(|p| p.exists())
.ok_or_else(|| anyhow::anyhow!("No OpenAPI spec found. Use --spec to specify the path."))
}
fn find_operation<'a>(spec: &'a UnifiedSpec, operation_id: &str) -> Result<&'a UnifiedOperation> {
let search = operation_id.to_lowercase();
if let Some(op) = spec
.operations
.iter()
.find(|op| op.operation_id.to_lowercase() == search)
{
return Ok(op);
}
let matches: Vec<&UnifiedOperation> = spec
.operations
.iter()
.filter(|op| op.operation_id.to_lowercase().contains(&search))
.collect();
match matches.len() {
0 => Err(anyhow::anyhow!(
"Operation '{}' not found in spec",
operation_id
)),
1 => Ok(matches[0]),
_ => Err(anyhow::anyhow!(
"Ambiguous: '{}' matches multiple operations: {}",
operation_id,
matches
.iter()
.map(|op| op.operation_id.as_str())
.collect::<Vec<_>>()
.join(", ")
)),
}
}
fn risk_from_method(method: &str) -> RiskLabel {
match method.to_uppercase().as_str() {
"GET" | "HEAD" | "OPTIONS" => RiskLabel {
level: "low".to_string(),
display: "low".to_string(),
},
"DELETE" => RiskLabel {
level: "high".to_string(),
display: "HIGH".to_string(),
},
_ => RiskLabel {
level: "medium".to_string(),
display: "medium".to_string(),
},
}
}
fn resolve_auth_scheme(scheme_type: &str, http_scheme: Option<&str>) -> &'static str {
match http_scheme {
Some("bearer") => "bearer",
Some("basic") => "basic",
_ => match scheme_type {
"apiKey" => "api-key",
"oauth2" => "oauth2",
"openIdConnect" => "oidc",
_ => "unknown",
},
}
}
fn auth_from_operation(operation: &UnifiedOperation, spec: &UnifiedSpec) -> AuthLabel {
let security = match operation.security.as_ref() {
Some(s) => s,
None => {
return AuthLabel {
required: false,
scheme: "none".to_string(),
}
}
};
let req = match security.first() {
Some(r) => r,
None => {
return AuthLabel {
required: false,
scheme: "none".to_string(),
}
}
};
let scheme_str = match spec.security_schemes.get(&req.scheme_name) {
Some(scheme) => resolve_auth_scheme(&scheme.scheme_type, scheme.scheme.as_deref()),
None => &req.scheme_name,
};
AuthLabel {
required: true,
scheme: scheme_str.to_string(),
}
}
fn analyze_gaps(operation: &UnifiedOperation, auth: &AuthLabel) -> StepReadiness {
let mut gaps = Vec::new();
for p in &operation.parameters {
if p.required && p.location == ParameterLocation::Path {
gaps.push(StepGap {
kind: "required_param".to_string(),
detail: format!("path param '{}' required", p.name),
});
}
}
for p in &operation.parameters {
if p.required && p.location == ParameterLocation::Query {
gaps.push(StepGap {
kind: "required_param".to_string(),
detail: format!("query param '{}' required", p.name),
});
}
}
if let Some(ref body) = operation.request_body {
if body.required {
gaps.push(StepGap {
kind: "required_body".to_string(),
detail: "request body required".to_string(),
});
}
}
if auth.required {
gaps.push(StepGap {
kind: "auth_required".to_string(),
detail: format!("{} authentication required", auth.scheme),
});
}
let status = if gaps.is_empty() {
"READY".to_string()
} else {
"NEEDS_INPUT".to_string()
};
StepReadiness { status, gaps }
}
fn build_step(
index: usize,
operation: &UnifiedOperation,
spec: &UnifiedSpec,
depends_on: &[String],
critical: bool,
) -> PlanStep {
let path_params: Vec<String> = operation
.parameters
.iter()
.filter(|p| p.location == ParameterLocation::Path)
.map(|p| p.name.clone())
.collect();
let requires_body = operation.request_body.is_some();
let auth = auth_from_operation(operation, spec);
let readiness = analyze_gaps(operation, &auth);
PlanStep {
step: index,
operation_id: operation.operation_id.clone(),
method: operation.method.to_uppercase(),
path: operation.path.clone(),
summary: operation.summary.clone(),
risk: risk_from_method(&operation.method),
auth,
path_params,
requires_body,
depends_on: depends_on.to_vec(),
critical,
readiness,
}
}
fn unresolved_step(index: usize, req: &crate::collections::models::CollectionRequest) -> PlanStep {
PlanStep {
step: index,
operation_id: req.operation.clone(),
method: "???".to_string(),
path: "(not found in spec)".to_string(),
summary: None,
risk: RiskLabel {
level: "unknown".to_string(),
display: "UNKNOWN".to_string(),
},
auth: AuthLabel {
required: false,
scheme: "unknown".to_string(),
},
path_params: vec![],
requires_body: false,
depends_on: req.depends_on.as_ref().cloned().unwrap_or_default(),
critical: req.critical,
readiness: StepReadiness {
status: "UNRESOLVED".to_string(),
gaps: vec![StepGap {
kind: "unresolved".to_string(),
detail: "operation not found in spec".to_string(),
}],
},
}
}
fn generate_sketch_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("sketch_{:x}", ts & 0xFFFF)
}
fn derive_plan_risk(summary: &RiskSummary) -> String {
if summary.destructive > 0 {
"HIGH (contains destructive operations)".to_string()
} else if summary.write > 0 {
"MEDIUM (contains write operations)".to_string()
} else {
"LOW (read-only)".to_string()
}
}
fn build_output(steps: Vec<PlanStep>) -> PlanSketchOutput {
let total = steps.len();
let mut read = 0;
let mut write = 0;
let mut destructive = 0;
for s in &steps {
match s.risk.level.as_str() {
"low" => read += 1,
"medium" => write += 1,
"high" => destructive += 1,
_ => {}
}
}
let risk_summary = RiskSummary {
read,
write,
destructive,
};
let plan_risk = derive_plan_risk(&risk_summary);
let mut ready = 0;
let mut needs_input = 0;
let mut unresolved = 0;
for s in &steps {
match s.readiness.status.as_str() {
"READY" => ready += 1,
"NEEDS_INPUT" => needs_input += 1,
"UNRESOLVED" => unresolved += 1,
_ => {}
}
}
let readiness_summary = Some(ReadinessSummary {
ready,
needs_input,
unresolved,
});
PlanSketchOutput {
sketch_id: generate_sketch_id(),
steps,
total_steps: total,
plan_risk,
risk_summary,
readiness_summary,
note: "Sketch only — not executable. Use claim → preview → run for each step.".to_string(),
}
}
fn display_text(sketch: &PlanSketchOutput, show_gaps: bool) {
println!(
"\n{} {} steps (not executable — sketch only) [{}]",
"Plan:".bold(),
sketch.total_steps,
sketch.sketch_id.dimmed(),
);
let risk_colored = if sketch.risk_summary.destructive > 0 {
sketch.plan_risk.red().bold()
} else if sketch.risk_summary.write > 0 {
sketch.plan_risk.yellow()
} else {
sketch.plan_risk.green()
};
println!(" Plan risk: {}", risk_colored);
if show_gaps {
if let Some(ref rs) = sketch.readiness_summary {
display_readiness_summary(rs);
}
}
println!();
for step in &sketch.steps {
let method_colored = match step.method.as_str() {
"GET" => step.method.green(),
"POST" => step.method.yellow(),
"PUT" => step.method.blue(),
"PATCH" => step.method.cyan(),
"DELETE" => step.method.red(),
_ => step.method.white(),
};
let risk_colored = match step.risk.level.as_str() {
"low" => step.risk.display.green(),
"medium" => step.risk.display.yellow(),
"high" => format!("{} risk", step.risk.display).red().bold(),
_ => step.risk.display.dimmed(),
};
let auth_str = if step.auth.required {
format!("auth: {}", step.auth.scheme).dimmed().to_string()
} else {
"no auth".dimmed().to_string()
};
print!(
" {:>2}. {:<30} [{}] {} {}",
step.step,
step.operation_id.bright_cyan(),
method_colored,
risk_colored,
auth_str,
);
if step.critical {
print!(" {}", "CRITICAL".red().bold());
}
if step.requires_body {
print!(" {}", "+body".dimmed());
}
if show_gaps {
let badge = match step.readiness.status.as_str() {
"READY" => "READY".green().bold(),
"NEEDS_INPUT" => "NEEDS_INPUT".yellow().bold(),
"UNRESOLVED" => "UNRESOLVED".red().bold(),
_ => step.readiness.status.dimmed(),
};
print!(" {}", badge);
}
println!();
println!(" {} {}", step.method.dimmed(), step.path.dimmed());
if let Some(ref summary) = step.summary {
println!(" {}", summary.dimmed());
}
if !step.path_params.is_empty() {
println!(" params: {}", step.path_params.join(", ").dimmed());
}
if !step.depends_on.is_empty() {
println!(
" {} {}",
"depends on:".dimmed(),
step.depends_on.join(", ").yellow()
);
}
if show_gaps {
display_step_gaps(&step.readiness.gaps);
}
println!();
}
let summary = &sketch.risk_summary;
println!("{}", " ─────────────────────────────────────────".dimmed());
print!(" Risk: ");
if summary.read > 0 {
print!("{} read ", format!("{}", summary.read).green());
}
if summary.write > 0 {
print!("{} write ", format!("{}", summary.write).yellow());
}
if summary.destructive > 0 {
print!(
"{}",
format!("{} destructive", summary.destructive).red().bold()
);
}
println!();
println!();
println!(
" {}",
"Order as provided. Dependencies not auto-resolved.".dimmed()
);
println!(
" {}",
"Execution today: claim → preview → run (per step)".dimmed()
);
println!(" {}", "Future: grouped execution (experimental)".dimmed());
println!();
}
fn display_readiness_summary(rs: &ReadinessSummary) {
print!(" Readiness: ");
if rs.ready > 0 {
print!("{} ready ", format!("{}", rs.ready).green());
}
if rs.needs_input > 0 {
print!("{} needs input ", format!("{}", rs.needs_input).yellow());
}
if rs.unresolved > 0 {
print!("{} unresolved", format!("{}", rs.unresolved).red());
}
println!();
}
fn display_step_gaps(gaps: &[StepGap]) {
for gap in gaps {
println!(" {} {}", "gap:".yellow(), gap.detail);
}
}
fn strip_readiness(value: &mut serde_json::Value) {
let obj = match value.as_object_mut() {
Some(o) => o,
None => return,
};
obj.remove("readiness_summary");
let steps = match obj.get_mut("steps").and_then(|s| s.as_array_mut()) {
Some(a) => a,
None => return,
};
for step in steps {
if let Some(step_obj) = step.as_object_mut() {
step_obj.remove("readiness");
}
}
}
fn display_json(sketch: &PlanSketchOutput, show_gaps: bool) {
if show_gaps {
match serde_json::to_string_pretty(sketch) {
Ok(json) => println!("{}", json),
Err(e) => eprintln!("Failed to serialize plan: {}", e),
}
} else {
let mut value = match serde_json::to_value(sketch) {
Ok(v) => v,
Err(e) => {
eprintln!("Failed to serialize plan: {}", e);
return;
}
};
strip_readiness(&mut value);
match serde_json::to_string_pretty(&value) {
Ok(json) => println!("{}", json),
Err(e) => eprintln!("Failed to serialize plan: {}", e),
}
}
}