#![expect( clippy::disallowed_methods, reason = "build script: runs at compile time outside any runtime" )] use std::path::{Path, PathBuf}; use std::{env, fs}; use progenitor::{GenerationSettings, Generator, InterfaceStyle, TypeImpl}; /// Recursively convert OpenAPI 3.1 `type: "null"` patterns to 3.0 `nullable: /// true`. /// /// Handles two patterns: /// - `oneOf: [{...}, {type: "null"}]` → the non-null schema with `nullable: /// true` /// - `type: [T1, ..., "null"]` → the remaining types with `nullable: true` fn patch_nullable(value: &mut serde_json::Value) { match value { serde_json::Value::Object(map) => { // Pattern: oneOf with a {type: "null"} variant if let Some(one_of) = map.get_mut("oneOf") { if let Some(variants) = one_of.as_array_mut() { let null_idx = variants.iter().position(|v| { v.get("type").and_then(serde_json::Value::as_str) == Some("null") }); if let Some(idx) = null_idx { variants.remove(idx); if variants.len() == 1 { // Collapse single-variant oneOf into the schema itself let mut inner = variants.remove(0); inner .as_object_mut() .expect("oneOf collapse should leave an object schema") .insert("nullable".to_string(), serde_json::Value::Bool(true)); patch_nullable(&mut inner); *value = inner; return; } map.insert("nullable".to_string(), serde_json::Value::Bool(true)); } } } // Pattern: type array containing "null" let needs_nullable_from_type = map .get("type") .and_then(|v| v.as_array()) .is_some_and(|arr| arr.iter().any(|v| v.as_str() == Some("null"))); if needs_nullable_from_type { if let Some(type_val) = map.get_mut("type") { if let Some(arr) = type_val.as_array_mut() { arr.retain(|v| v.as_str() != Some("null")); if arr.len() == 1 { *type_val = arr.remove(0); } } } map.insert("nullable".to_string(), serde_json::Value::Bool(true)); } for v in map.values_mut() { patch_nullable(v); } } serde_json::Value::Array(arr) => { for v in arr { patch_nullable(v); } } _ => {} } } /// Progenitor currently panics when an operation advertises more than one /// request-body media type. /// /// Keep the source OpenAPI spec accurate for docs, but collapse the /// generated-client view down to a single preferred media type so code /// generation can proceed. fn patch_codegen_request_body_media_types(value: &mut serde_json::Value) { let Some(paths) = value .get_mut("paths") .and_then(serde_json::Value::as_object_mut) else { return; }; for path_item in paths.values_mut() { let Some(item) = path_item.as_object_mut() else { continue; }; for method in ["get", "put", "post", "delete", "patch"] { let Some(operation) = item .get_mut(method) .and_then(serde_json::Value::as_object_mut) else { continue; }; let Some(content) = operation .get_mut("requestBody") .and_then(|request_body| request_body.get_mut("content")) .and_then(serde_json::Value::as_object_mut) else { continue; }; if content.len() <= 1 { continue; } let preferred = content .get("application/octet-stream") .cloned() .map(|value| ("application/octet-stream".to_string(), value)) .or_else(|| { content .iter() .next() .map(|(key, value)| (key.clone(), value.clone())) }); if let Some((key, value)) = preferred { content.clear(); content.insert(key, value); } } } } fn spec_path_from_manifest_dir(manifest_dir: &Path) -> PathBuf { manifest_dir .ancestors() .nth(3) .expect("fabro-api manifest dir should be nested under /lib/crates/fabro-api") .join("docs/public/api-reference/fabro-api.yaml") } fn main() { let manifest_dir = env::var_os("CARGO_MANIFEST_DIR") .map(PathBuf::from) .expect("CARGO_MANIFEST_DIR should be set for build scripts"); let spec_path = spec_path_from_manifest_dir(&manifest_dir); println!("cargo::rerun-if-changed={}", spec_path.display()); let spec_text = fs::read_to_string(&spec_path) .unwrap_or_else(|e| panic!("failed to read {}: {e}", spec_path.display())); let mut spec_value: serde_json::Value = serde_yaml::from_str(&spec_text).unwrap_or_else(|e| panic!("failed to parse YAML: {e}")); // TODO: Remove 3.1→3.0 patch when progenitor supports OpenAPI 3.1. // Progenitor only supports OpenAPI 3.0.x; our spec uses 3.1.0 but doesn't // rely on any 3.1-only features that affect codegen. spec_value["openapi"] = serde_json::Value::String("3.0.3".to_string()); patch_nullable(&mut spec_value); patch_codegen_request_body_media_types(&mut spec_value); let spec: openapiv3::OpenAPI = serde_json::from_value(spec_value).expect("failed to deserialize OpenAPI spec"); let mut settings = GenerationSettings::default(); settings.with_interface(InterfaceStyle::Builder); let replacements: &[(&str, &str, &[TypeImpl])] = &[ ("RunStatus", "fabro_types::status::RunStatus", &[]), ("SuccessReason", "fabro_types::status::SuccessReason", &[]), ("FailureReason", "fabro_types::status::FailureReason", &[]), ("TerminalStatus", "fabro_types::status::TerminalStatus", &[]), ("BlockedReason", "fabro_types::status::BlockedReason", &[]), ( "RunControlAction", "fabro_types::status::RunControlAction", &[], ), ("RunSummary", "fabro_types::RunSummary", &[]), ( "RepositoryReference", "fabro_types::RepositoryReference", &[], ), ("WorkflowSettings", "fabro_types::WorkflowSettings", &[]), ("ServerSettings", "fabro_types::ServerSettings", &[]), ( "ServerNamespace", "fabro_types::settings::ServerNamespace", &[], ), ( "FeaturesNamespace", "fabro_types::settings::FeaturesNamespace", &[], ), ( "ServerListenSettings", "fabro_types::settings::server::ServerListenSettings", &[], ), ( "ServerApiSettings", "fabro_types::settings::server::ServerApiSettings", &[], ), ( "ServerWebSettings", "fabro_types::settings::server::ServerWebSettings", &[], ), ( "ServerAuthSettings", "fabro_types::settings::server::ServerAuthSettings", &[], ), ( "ServerAuthMethod", "fabro_types::settings::server::ServerAuthMethod", &[], ), ( "ServerAuthGithubSettings", "fabro_types::settings::server::ServerAuthGithubSettings", &[], ), ( "ServerIpAllowlistSettings", "fabro_types::settings::server::ServerIpAllowlistSettings", &[], ), ( "ServerIpAllowlistOverrideSettings", "fabro_types::settings::server::ServerIpAllowlistOverrideSettings", &[], ), ( "IpAllowEntry", "fabro_types::settings::server::IpAllowEntry", &[], ), ( "ServerStorageSettings", "fabro_types::settings::server::ServerStorageSettings", &[], ), ( "ServerArtifactsSettings", "fabro_types::settings::server::ServerArtifactsSettings", &[], ), ( "ServerSlateDbSettings", "fabro_types::settings::server::ServerSlateDbSettings", &[], ), ( "ObjectStoreSettings", "fabro_types::settings::server::ObjectStoreSettings", &[], ), ( "ServerSchedulerSettings", "fabro_types::settings::server::ServerSchedulerSettings", &[], ), ( "ServerLoggingSettings", "fabro_types::settings::server::ServerLoggingSettings", &[], ), ( "LogDestination", "fabro_types::settings::server::LogDestination", &[], ), ( "ServerIntegrationsSettings", "fabro_types::settings::server::ServerIntegrationsSettings", &[], ), ( "GithubIntegrationSettings", "fabro_types::settings::server::GithubIntegrationSettings", &[], ), ( "GithubIntegrationStrategy", "fabro_types::settings::server::GithubIntegrationStrategy", &[], ), ( "SlackIntegrationSettings", "fabro_types::settings::server::SlackIntegrationSettings", &[], ), ( "DiscordIntegrationSettings", "fabro_types::settings::server::DiscordIntegrationSettings", &[], ), ( "TeamsIntegrationSettings", "fabro_types::settings::server::TeamsIntegrationSettings", &[], ), ( "IntegrationWebhooksSettings", "fabro_types::settings::server::IntegrationWebhooksSettings", &[], ), ( "WebhookStrategy", "fabro_types::settings::server::WebhookStrategy", &[], ), ("ActorKind", "fabro_types::ActorKind", &[]), ("ActorRef", "fabro_types::ActorRef", &[]), ("QuestionType", "fabro_types::QuestionType", &[]), ("NodeStatusRecord", "fabro_types::NodeStatusRecord", &[]), ("StageOutcome", "fabro_types::StageOutcome", &[]), ("StageState", "fabro_types::StageState", &[]), ( "CommandOutputStream", "fabro_types::CommandOutputStream", &[], ), ("CommandTermination", "fabro_types::CommandTermination", &[]), ("NodeState", "fabro_types::NodeState", &[]), ("SecretMetadata", "fabro_types::SecretMetadata", &[]), ("InterviewOption", "fabro_types::InterviewOption", &[]), ( "InterviewQuestionRecord", "fabro_types::InterviewQuestionRecord", &[], ), ( "PendingInterviewRecord", "fabro_types::PendingInterviewRecord", &[], ), ("BilledTokenCounts", "fabro_types::BilledTokenCounts", &[]), ("Provider", "fabro_model::Provider", &[]), ("Model", "fabro_model::Model", &[]), ("ModelLimits", "fabro_model::ModelLimits", &[]), ("ModelFeatures", "fabro_model::ModelFeatures", &[]), ("ModelCosts", "fabro_model::ModelCosts", &[]), ("ModelTestMode", "fabro_model::ModelTestMode", &[]), ("RunProjection", "fabro_types::RunProjection", &[]), ("RunEvent", "fabro_types::RunEvent", &[]), ("EventEnvelope", "fabro_types::EventEnvelope", &[]), ("PullRequestRecord", "fabro_types::PullRequestRecord", &[]), ("PullRequestDetail", "fabro_types::PullRequestDetail", &[]), ("PullRequestUser", "fabro_types::PullRequestUser", &[]), ("PullRequestRef", "fabro_types::PullRequestRef", &[]), ( "MergeMethod", "fabro_types::settings::run::MergeStrategy", &[], ), ("SecretType", "fabro_types::SecretType", &[]), ("DiffStats", "fabro_types::DiffStats", &[]), ("PreRunPushOutcome", "fabro_types::PreRunPushOutcome", &[]), ("DirtyStatus", "fabro_types::DirtyStatus", &[]), ("GitContext", "fabro_types::GitContext", &[]), ]; for (name, path, impls) in replacements { settings.with_replacement(*name, *path, impls.iter().copied()); } let mut generator = Generator::new(&settings); let tokens = generator .generate_tokens(&spec) .expect("failed to generate tokens from OpenAPI spec"); let syntax_tree = syn::parse2::(tokens).expect("failed to parse generated tokens"); let formatted = prettyplease::unparse(&syntax_tree); let out_dir = env::var("OUT_DIR").expect("OUT_DIR should be set for build scripts"); let out_path = Path::new(&out_dir).join("codegen.rs"); fs::write(&out_path, formatted).expect("failed to write generated code"); }