use crate::commands::cli_session::{
post_openapi_generation_upload, resolve_cli_access_token, CliOpenApiArtifactUpload,
CliOpenApiGenerationUploadPayload,
};
use crate::config::{ensure_global_xbp_paths, ApiConfig};
use crate::utils::{git_remote_url_from_metadata, parse_github_repo_from_remote_url};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct OpenApiArchiveArtifact {
pub path: PathBuf,
pub contents: String,
pub service_name: String,
pub service_version: String,
pub kind: OpenApiArtifactKind,
pub xbp_cli_version: String,
pub path_count: u32,
pub operation_count: u32,
pub schema_count: u32,
pub server_count: u32,
pub websocket_operation_count: u32,
pub openapi_dialect: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum OpenApiArtifactKind {
Service,
Aggregate,
}
impl OpenApiArtifactKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Service => "service",
Self::Aggregate => "aggregate",
}
}
}
const MANIFEST_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenApiGenerationManifest {
pub schema_version: u32,
pub updated_at: String,
pub repository_owner: String,
pub repository_name: String,
pub branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub xbp_cli_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<OpenApiManifestSummary>,
pub generations: Vec<OpenApiGenerationEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenApiManifestSummary {
pub generation_count: usize,
pub unique_services: usize,
pub total_files: usize,
pub total_bytes: u64,
pub total_paths: u64,
pub total_operations: u64,
pub total_schemas: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_generated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_xbp_cli_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenApiGenerationEntry {
pub service_name: String,
pub service_version: String,
pub iteration: u32,
pub generated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub xbp_cli_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openapi_dialect: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stats: Option<OpenApiGenerationStats>,
pub files: Vec<OpenApiGenerationFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote: Option<OpenApiGenerationRemote>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenApiGenerationStats {
pub path_count: u32,
pub operation_count: u32,
pub schema_count: u32,
pub server_count: u32,
pub websocket_operation_count: u32,
pub file_count: u32,
pub total_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenApiGenerationFile {
pub filename: String,
pub format: String,
pub bytes: u64,
pub sha256: String,
pub relative_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenApiGenerationRemote {
pub uploaded_at: String,
pub keys: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub public_urls: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct OpenApiArchiveResult {
pub global_dirs: Vec<PathBuf>,
pub project_dirs: Vec<PathBuf>,
pub uploaded: usize,
pub upload_skipped_reason: Option<String>,
}
pub async fn archive_and_upload_openapi_generations(
project_root: &Path,
artifacts: &[OpenApiArchiveArtifact],
) -> Result<OpenApiArchiveResult, String> {
if artifacts.is_empty() {
return Ok(OpenApiArchiveResult {
global_dirs: Vec::new(),
project_dirs: Vec::new(),
uploaded: 0,
upload_skipped_reason: Some("no artifacts".into()),
});
}
let (owner, repo) = resolve_repo_identity(project_root);
let branch = resolve_git_branch(project_root).unwrap_or_else(|| "unknown".into());
let generated_at = Utc::now().to_rfc3339();
let mut global_dirs = Vec::new();
let mut project_dirs = Vec::new();
let mut upload_batches: Vec<(String, String, u32, Vec<OpenApiArchiveArtifact>)> = Vec::new();
let mut groups: std::collections::BTreeMap<(String, String), Vec<&OpenApiArchiveArtifact>> =
std::collections::BTreeMap::new();
for artifact in artifacts {
groups
.entry((
artifact.service_name.clone(),
artifact.service_version.clone(),
))
.or_default()
.push(artifact);
}
for ((service_name, service_version), group) in groups {
let global_base = global_generation_base(&owner, &repo, &branch, &service_name, &service_version)?;
let project_base =
project_generation_base(project_root, &service_name, &service_version);
let iteration = next_iteration(&global_base)?
.max(next_iteration(&project_base)?);
let global_dir = global_base.join(iteration.to_string());
let project_dir = project_base.join(iteration.to_string());
fs::create_dir_all(&global_dir).map_err(|e| e.to_string())?;
fs::create_dir_all(&project_dir).map_err(|e| e.to_string())?;
let mut files = Vec::new();
let mut group_owned = Vec::new();
let mut total_bytes = 0u64;
let first = group.first().copied();
let kind = first.map(|a| a.kind).unwrap_or(OpenApiArtifactKind::Service);
let xbp_cli_version = first
.map(|a| a.xbp_cli_version.clone())
.unwrap_or_else(|| "unknown".into());
let openapi_dialect = first
.map(|a| a.openapi_dialect.clone())
.unwrap_or_default();
let path_count = first.map(|a| a.path_count).unwrap_or(0);
let operation_count = first.map(|a| a.operation_count).unwrap_or(0);
let schema_count = first.map(|a| a.schema_count).unwrap_or(0);
let server_count = first.map(|a| a.server_count).unwrap_or(0);
let websocket_operation_count = first.map(|a| a.websocket_operation_count).unwrap_or(0);
for artifact in group {
let filename = artifact
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("openapi.yaml")
.to_string();
let format = if filename.ends_with(".json") {
"json"
} else {
"yaml"
}
.to_string();
let sha = sha256_hex(artifact.contents.as_bytes());
let bytes = artifact.contents.len() as u64;
total_bytes = total_bytes.saturating_add(bytes);
write_file(&global_dir.join(&filename), &artifact.contents)?;
write_file(&project_dir.join(&filename), &artifact.contents)?;
files.push(OpenApiGenerationFile {
filename: filename.clone(),
format,
bytes,
sha256: sha,
relative_path: format!(
"{}/{}/{}",
sanitize_segment(&service_name),
sanitize_segment(&service_version),
iteration
),
});
group_owned.push(artifact.clone());
}
let stats = OpenApiGenerationStats {
path_count,
operation_count,
schema_count,
server_count,
websocket_operation_count,
file_count: files.len() as u32,
total_bytes,
};
let entry = OpenApiGenerationEntry {
service_name: service_name.clone(),
service_version: service_version.clone(),
iteration,
generated_at: generated_at.clone(),
kind: Some(kind.as_str().to_string()),
xbp_cli_version: Some(xbp_cli_version.clone()),
openapi_dialect: (!openapi_dialect.is_empty()).then_some(openapi_dialect.clone()),
stats: Some(stats.clone()),
files,
remote: None,
};
upsert_manifest(
&global_manifest_path(&owner, &repo, &branch)?,
&owner,
&repo,
&branch,
&xbp_cli_version,
entry.clone(),
)?;
upsert_manifest(
&project_manifest_path(project_root),
&owner,
&repo,
&branch,
&xbp_cli_version,
entry,
)?;
write_readme(
&global_dir,
&owner,
&repo,
&branch,
&service_name,
&service_version,
iteration,
&xbp_cli_version,
kind,
&stats,
openapi_dialect.as_str(),
)?;
write_readme(
&project_dir,
&owner,
&repo,
&branch,
&service_name,
&service_version,
iteration,
&xbp_cli_version,
kind,
&stats,
openapi_dialect.as_str(),
)?;
write_tree_readme(
project_root.join(".xbp/openapi-generations"),
&owner,
&repo,
&branch,
&xbp_cli_version,
)?;
write_tree_readme(
ensure_global_xbp_paths()?
.root_dir
.join("openapi-generations")
.join(sanitize_segment(&owner))
.join(sanitize_segment(&repo))
.join(sanitize_segment(&branch)),
&owner,
&repo,
&branch,
&xbp_cli_version,
)?;
global_dirs.push(global_dir);
project_dirs.push(project_dir);
upload_batches.push((service_name, service_version, iteration, group_owned));
}
let mut uploaded = 0usize;
let mut upload_skipped_reason = None;
if resolve_cli_access_token().is_err() {
upload_skipped_reason = Some("not logged in (run `xbp login`)".into());
} else {
for (service_name, service_version, iteration, group) in upload_batches {
let artifacts_payload: Vec<CliOpenApiArtifactUpload> = group
.iter()
.map(|artifact| {
let filename = artifact
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("openapi.yaml")
.to_string();
let format = if filename.ends_with(".json") {
"json"
} else {
"yaml"
}
.to_string();
CliOpenApiArtifactUpload {
filename,
format,
content: artifact.contents.clone(),
content_type: if artifact.path.extension().and_then(|e| e.to_str())
== Some("json")
{
"application/json".into()
} else {
"application/yaml".into()
},
sha256: sha256_hex(artifact.contents.as_bytes()),
}
})
.collect();
let payload = CliOpenApiGenerationUploadPayload {
repository_owner: owner.clone(),
repository_name: repo.clone(),
branch: branch.clone(),
service_name: service_name.clone(),
service_version: service_version.clone(),
iteration,
generated_at: generated_at.clone(),
project_path: Some(project_root.display().to_string()),
artifacts: artifacts_payload,
};
match post_openapi_generation_upload(&payload).await {
Ok(Some(response)) => {
uploaded += response.uploaded;
let remote = OpenApiGenerationRemote {
uploaded_at: Utc::now().to_rfc3339(),
keys: response.keys.clone(),
public_urls: response.public_urls.clone(),
};
let _ = patch_manifest_remote(
&global_manifest_path(&owner, &repo, &branch)?,
&service_name,
&service_version,
iteration,
remote.clone(),
);
let _ = patch_manifest_remote(
&project_manifest_path(project_root),
&service_name,
&service_version,
iteration,
remote,
);
for url in &response.public_urls {
if !url.trim().is_empty() {
println!("public {}", url);
}
}
}
Ok(None) => {
upload_skipped_reason =
Some("xbp.app upload unavailable (route missing or unauthorized)".into());
}
Err(error) => {
upload_skipped_reason = Some(error);
}
}
}
}
Ok(OpenApiArchiveResult {
global_dirs,
project_dirs,
uploaded,
upload_skipped_reason,
})
}
fn resolve_repo_identity(project_root: &Path) -> (String, String) {
git_remote_url_from_metadata(project_root, "origin")
.ok()
.flatten()
.and_then(|url| parse_github_repo_from_remote_url(&url))
.unwrap_or_else(|| {
let name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("local")
.to_string();
("local".into(), name)
})
}
fn resolve_git_branch(project_root: &Path) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(project_root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch.is_empty() || branch == "HEAD" {
None
} else {
Some(branch)
}
}
fn sanitize_segment(raw: &str) -> String {
let cleaned: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'-'
}
})
.collect();
let trimmed = cleaned.trim_matches('.').trim_matches('-');
if trimmed.is_empty() {
"unknown".into()
} else {
trimmed.to_string()
}
}
fn global_generation_base(
owner: &str,
repo: &str,
branch: &str,
service: &str,
version: &str,
) -> Result<PathBuf, String> {
let paths = ensure_global_xbp_paths()?;
Ok(paths
.root_dir
.join("openapi-generations")
.join(sanitize_segment(owner))
.join(sanitize_segment(repo))
.join(sanitize_segment(branch))
.join(sanitize_segment(service))
.join(sanitize_segment(version)))
}
fn project_generation_base(project_root: &Path, service: &str, version: &str) -> PathBuf {
project_root
.join(".xbp")
.join("openapi-generations")
.join(sanitize_segment(service))
.join(sanitize_segment(version))
}
fn global_manifest_path(owner: &str, repo: &str, branch: &str) -> Result<PathBuf, String> {
let paths = ensure_global_xbp_paths()?;
Ok(paths
.root_dir
.join("openapi-generations")
.join(sanitize_segment(owner))
.join(sanitize_segment(repo))
.join(sanitize_segment(branch))
.join("manifest.json"))
}
fn project_manifest_path(project_root: &Path) -> PathBuf {
project_root
.join(".xbp")
.join("openapi-generations")
.join("manifest.json")
}
fn next_iteration(base: &Path) -> Result<u32, String> {
if !base.exists() {
return Ok(1);
}
let mut max = 0u32;
for entry in fs::read_dir(base).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
if let Some(n) = entry
.file_name()
.to_str()
.and_then(|s| s.parse::<u32>().ok())
{
max = max.max(n);
}
}
Ok(max.saturating_add(1).max(1))
}
fn write_file(path: &Path, contents: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(path, contents).map_err(|e| format!("failed to write {}: {e}", path.display()))
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn upsert_manifest(
path: &Path,
owner: &str,
repo: &str,
branch: &str,
xbp_cli_version: &str,
entry: OpenApiGenerationEntry,
) -> Result<(), String> {
let mut manifest = if path.exists() {
let raw = fs::read_to_string(path).map_err(|e| e.to_string())?;
serde_json::from_str::<OpenApiGenerationManifest>(&raw).unwrap_or_else(|_| {
empty_manifest(owner, repo, branch, xbp_cli_version)
})
} else {
empty_manifest(owner, repo, branch, xbp_cli_version)
};
manifest.schema_version = MANIFEST_SCHEMA_VERSION;
manifest.updated_at = Utc::now().to_rfc3339();
manifest.repository_owner = owner.to_string();
manifest.repository_name = repo.to_string();
manifest.branch = branch.to_string();
manifest.xbp_cli_version = Some(xbp_cli_version.to_string());
manifest.generations.insert(0, entry);
if manifest.generations.len() > 100 {
manifest.generations.truncate(100);
}
manifest.summary = Some(compute_manifest_summary(&manifest.generations));
let pretty = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
write_file(path, &format!("{pretty}\n"))
}
fn empty_manifest(
owner: &str,
repo: &str,
branch: &str,
xbp_cli_version: &str,
) -> OpenApiGenerationManifest {
OpenApiGenerationManifest {
schema_version: MANIFEST_SCHEMA_VERSION,
updated_at: Utc::now().to_rfc3339(),
repository_owner: owner.to_string(),
repository_name: repo.to_string(),
branch: branch.to_string(),
xbp_cli_version: Some(xbp_cli_version.to_string()),
summary: None,
generations: Vec::new(),
}
}
fn compute_manifest_summary(generations: &[OpenApiGenerationEntry]) -> OpenApiManifestSummary {
let mut unique_services = std::collections::BTreeSet::new();
let mut total_files = 0usize;
let mut total_bytes = 0u64;
let mut total_paths = 0u64;
let mut total_operations = 0u64;
let mut total_schemas = 0u64;
for entry in generations {
unique_services.insert(entry.service_name.as_str());
total_files = total_files.saturating_add(entry.files.len());
for file in &entry.files {
total_bytes = total_bytes.saturating_add(file.bytes);
}
if let Some(stats) = &entry.stats {
total_paths = total_paths.saturating_add(u64::from(stats.path_count));
total_operations = total_operations.saturating_add(u64::from(stats.operation_count));
total_schemas = total_schemas.saturating_add(u64::from(stats.schema_count));
}
}
let latest = generations.first();
OpenApiManifestSummary {
generation_count: generations.len(),
unique_services: unique_services.len(),
total_files,
total_bytes,
total_paths,
total_operations,
total_schemas,
latest_generated_at: latest.map(|e| e.generated_at.clone()),
latest_service: latest.map(|e| e.service_name.clone()),
latest_xbp_cli_version: latest.and_then(|e| e.xbp_cli_version.clone()),
}
}
fn patch_manifest_remote(
path: &Path,
service: &str,
version: &str,
iteration: u32,
remote: OpenApiGenerationRemote,
) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let raw = fs::read_to_string(path).map_err(|e| e.to_string())?;
let mut manifest: OpenApiGenerationManifest =
serde_json::from_str(&raw).map_err(|e| e.to_string())?;
for entry in &mut manifest.generations {
if entry.service_name == service
&& entry.service_version == version
&& entry.iteration == iteration
{
entry.remote = Some(remote);
break;
}
}
manifest.updated_at = Utc::now().to_rfc3339();
let pretty = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
write_file(path, &format!("{pretty}\n"))
}
fn write_readme(
dir: &Path,
owner: &str,
repo: &str,
branch: &str,
service: &str,
version: &str,
iteration: u32,
xbp_cli_version: &str,
kind: OpenApiArtifactKind,
stats: &OpenApiGenerationStats,
openapi_dialect: &str,
) -> Result<(), String> {
let dialect_row = if openapi_dialect.is_empty() {
String::new()
} else {
format!("| OpenAPI dialect | `{openapi_dialect}` |\n")
};
let body = format!(
"# OpenAPI generation\n\n\
| Field | Value |\n\
| --- | --- |\n\
| Repository | `{owner}/{repo}` |\n\
| Branch | `{branch}` |\n\
| Service | `{service}` |\n\
| Kind | `{}` |\n\
| API version | `{version}` |\n\
| Iteration | `{iteration}` |\n\
| **XBP CLI version** | **`v{xbp_cli_version}`** |\n\
{dialect_row}\
| Paths | `{}` |\n\
| Operations | `{}` |\n\
| Schemas | `{}` |\n\
| Servers | `{}` |\n\
| WebSocket ops | `{}` |\n\
| Files | `{}` (`{}` bytes) |\n\
| Generated | `{}` |\n\n\
Files in this directory were produced by **xbp CLI v{xbp_cli_version}** via \
`xbp generate openapi`.\n\
See `manifest.json` one level up (project) or at the branch root (global) for the full index.\n",
kind.as_str(),
stats.path_count,
stats.operation_count,
stats.schema_count,
stats.server_count,
stats.websocket_operation_count,
stats.file_count,
stats.total_bytes,
Utc::now().to_rfc3339()
);
write_file(&dir.join("README.md"), &body)
}
fn write_tree_readme(
dir: PathBuf,
owner: &str,
repo: &str,
branch: &str,
xbp_cli_version: &str,
) -> Result<(), String> {
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let body = format!(
"# OpenAPI generations\n\n\
Self-updating archive for `{owner}/{repo}` on `{branch}`.\n\n\
Last written by **xbp CLI v{xbp_cli_version}** (`xbp generate openapi`).\n\n\
Layout:\n\n\
```text\n\
{{service}}/{{version}}/{{iteration}}/openapi.{{yaml,json}}\n\
manifest.json\n\
```\n\n\
Each generation entry in `manifest.json` records the XBP CLI version, path/operation/\n\
schema counts, file digests, and optional remote upload keys.\n\
Iterations increment automatically when the same service version is generated again.\n\n\
Last updated: {}\n",
Utc::now().to_rfc3339()
);
write_file(&dir.join("README.md"), &body)
}
#[allow(dead_code)]
fn _api_hint() -> String {
ApiConfig::load().cli_openapi_upload_endpoint()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitizes_path_segments() {
assert_eq!(sanitize_segment("athena-auth"), "athena-auth");
assert_eq!(sanitize_segment("feat/foo"), "feat-foo");
assert_eq!(sanitize_segment("../x"), "x");
assert_eq!(sanitize_segment(""), "unknown");
}
#[test]
fn iteration_starts_at_one() {
let dir = std::env::temp_dir().join(format!(
"xbp-openapi-iter-{}",
Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
let _ = fs::remove_dir_all(&dir);
assert_eq!(next_iteration(&dir).unwrap(), 1);
fs::create_dir_all(dir.join("1")).unwrap();
fs::create_dir_all(dir.join("2")).unwrap();
assert_eq!(next_iteration(&dir).unwrap(), 3);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn manifest_summary_aggregates_route_and_file_stats() {
let summary = compute_manifest_summary(&[OpenApiGenerationEntry {
service_name: "api".into(),
service_version: "1.0.0".into(),
iteration: 1,
generated_at: "2026-01-01T00:00:00Z".into(),
kind: Some("service".into()),
xbp_cli_version: Some("10.37.3".into()),
openapi_dialect: Some("3.1.0".into()),
stats: Some(OpenApiGenerationStats {
path_count: 4,
operation_count: 7,
schema_count: 12,
server_count: 2,
websocket_operation_count: 1,
file_count: 2,
total_bytes: 900,
}),
files: vec![
OpenApiGenerationFile {
filename: "openapi.yaml".into(),
format: "yaml".into(),
bytes: 500,
sha256: "a".into(),
relative_path: "api/1.0.0/1".into(),
},
OpenApiGenerationFile {
filename: "openapi.json".into(),
format: "json".into(),
bytes: 400,
sha256: "b".into(),
relative_path: "api/1.0.0/1".into(),
},
],
remote: None,
}]);
assert_eq!(summary.generation_count, 1);
assert_eq!(summary.unique_services, 1);
assert_eq!(summary.total_files, 2);
assert_eq!(summary.total_bytes, 900);
assert_eq!(summary.total_paths, 4);
assert_eq!(summary.total_operations, 7);
assert_eq!(summary.total_schemas, 12);
assert_eq!(summary.latest_xbp_cli_version.as_deref(), Some("10.37.3"));
}
}