use std::collections::BTreeMap;
use std::path::Path;
use crate::error::TarnError;
pub mod curl;
pub mod emit;
pub mod explicit;
pub mod openapi;
pub mod recorded;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceMode {
OpenApi,
Curl,
Explicit,
Recorded,
}
impl SourceMode {
pub fn as_str(&self) -> &'static str {
match self {
SourceMode::OpenApi => "openapi",
SourceMode::Curl => "curl",
SourceMode::Explicit => "explicit",
SourceMode::Recorded => "recorded",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TodoCategory {
Env,
Method,
Url,
PathParam,
Headers,
Auth,
Body,
Assertion,
Capture,
}
impl TodoCategory {
pub fn as_str(&self) -> &'static str {
match self {
TodoCategory::Env => "env",
TodoCategory::Method => "method",
TodoCategory::Url => "url",
TodoCategory::PathParam => "path_param",
TodoCategory::Headers => "headers",
TodoCategory::Auth => "auth",
TodoCategory::Body => "body",
TodoCategory::Assertion => "assertion",
TodoCategory::Capture => "capture",
}
}
}
#[derive(Debug, Clone)]
pub struct Todo {
pub category: TodoCategory,
pub message: String,
pub line: Option<usize>,
}
impl Todo {
pub fn new(category: TodoCategory, message: impl Into<String>) -> Self {
Self {
category,
message: message.into(),
line: None,
}
}
}
#[derive(Debug, Clone)]
pub enum BodyShape {
Json(serde_json::Value),
Raw(String),
}
#[derive(Debug, Clone)]
pub struct ScaffoldRequest {
pub file_name: String,
pub step_name: String,
pub method: String,
pub url: String,
pub headers: BTreeMap<String, String>,
pub sensitive_headers: Vec<String>,
pub body: Option<BodyShape>,
pub captures: BTreeMap<String, String>,
pub path_params: Vec<String>,
pub response_shape_keys: Vec<String>,
pub status_assertion: Option<String>,
}
impl ScaffoldRequest {
pub fn new(file_name: impl Into<String>, step_name: impl Into<String>) -> Self {
Self {
file_name: file_name.into(),
step_name: step_name.into(),
method: "GET".to_string(),
url: String::new(),
headers: BTreeMap::new(),
sensitive_headers: Vec::new(),
body: None,
captures: BTreeMap::new(),
path_params: Vec::new(),
response_shape_keys: Vec::new(),
status_assertion: None,
}
}
}
#[derive(Debug, Clone)]
pub struct ScaffoldResult {
pub source_mode: SourceMode,
pub request: ScaffoldRequest,
pub yaml: String,
pub todos: Vec<Todo>,
pub parsed_ok: bool,
pub schema_ok: bool,
}
#[derive(Debug, Clone)]
pub enum ScaffoldInput {
OpenApi {
spec_path: std::path::PathBuf,
op_id: String,
},
Curl {
curl_text: String,
source_label: String,
},
Explicit { method: String, url: String },
Recorded { path: std::path::PathBuf },
}
#[derive(Debug, Clone, Default)]
pub struct ScaffoldOptions {
pub name_override: Option<String>,
}
pub fn generate(
input: &ScaffoldInput,
options: &ScaffoldOptions,
) -> Result<ScaffoldResult, TarnError> {
let (source_mode, mut request, mut todos) = match input {
ScaffoldInput::OpenApi { spec_path, op_id } => {
let (req, todos) = openapi::scaffold_from_openapi(spec_path, op_id)?;
(SourceMode::OpenApi, req, todos)
}
ScaffoldInput::Curl {
curl_text,
source_label,
} => {
let (req, todos) = curl::scaffold_from_curl(curl_text, source_label)?;
(SourceMode::Curl, req, todos)
}
ScaffoldInput::Explicit { method, url } => {
let (req, todos) = explicit::scaffold_from_explicit(method, url)?;
(SourceMode::Explicit, req, todos)
}
ScaffoldInput::Recorded { path } => {
let (req, todos) = recorded::scaffold_from_recorded(path)?;
(SourceMode::Recorded, req, todos)
}
};
if let Some(name) = &options.name_override {
let trimmed = name.trim();
if !trimmed.is_empty() {
request.file_name = trimmed.to_string();
}
}
let yaml = emit::render(&request, &mut todos);
let parsed_ok = crate::parser::parse_str(&yaml, Path::new("scaffold.tarn.yaml")).is_ok();
if !parsed_ok {
return Err(TarnError::Validation(format!(
"tarn scaffold produced YAML that failed round-trip parsing. \
This is a bug in the scaffold generator, not your inputs. \
Generated YAML:\n---\n{yaml}\n---"
)));
}
let schema_ok = parsed_ok;
Ok(ScaffoldResult {
source_mode,
request,
yaml,
todos,
parsed_ok,
schema_ok,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_explicit_is_deterministic() {
let input = ScaffoldInput::Explicit {
method: "POST".into(),
url: "http://example.com/users".into(),
};
let options = ScaffoldOptions::default();
let a = generate(&input, &options).unwrap();
let b = generate(&input, &options).unwrap();
assert_eq!(a.yaml, b.yaml);
assert_eq!(a.todos.len(), b.todos.len());
for (ta, tb) in a.todos.iter().zip(b.todos.iter()) {
assert_eq!(ta.category, tb.category);
assert_eq!(ta.message, tb.message);
assert_eq!(ta.line, tb.line);
}
}
#[test]
fn generate_explicit_round_trips_through_parser() {
let input = ScaffoldInput::Explicit {
method: "GET".into(),
url: "http://example.com/health".into(),
};
let result = generate(&input, &ScaffoldOptions::default()).unwrap();
assert!(result.parsed_ok);
assert!(result.schema_ok);
assert!(result.yaml.contains("name:"));
assert!(result.yaml.contains("method: GET"));
assert!(result.yaml.contains("url: "));
}
#[test]
fn generate_emits_todos_with_populated_line_numbers() {
let input = ScaffoldInput::Explicit {
method: "POST".into(),
url: "http://example.com/widgets".into(),
};
let result = generate(&input, &ScaffoldOptions::default()).unwrap();
assert!(
!result.todos.is_empty(),
"minimal scaffold must carry at least one TODO"
);
for todo in &result.todos {
assert!(
todo.line.is_some(),
"rendered TODOs must have a line number: {:?}",
todo
);
}
}
#[test]
fn name_override_replaces_inferred_name() {
let input = ScaffoldInput::Explicit {
method: "GET".into(),
url: "http://example.com/health".into(),
};
let options = ScaffoldOptions {
name_override: Some("Custom smoke".into()),
};
let result = generate(&input, &options).unwrap();
assert!(result.yaml.contains("name: Custom smoke"));
}
}