1use crate::commands::cli_session::{
5 post_openapi_generation_upload, resolve_cli_access_token, CliOpenApiArtifactUpload,
6 CliOpenApiGenerationUploadPayload,
7};
8use crate::config::{ensure_global_xbp_paths, ApiConfig};
9use crate::utils::{git_remote_url_from_metadata, parse_github_repo_from_remote_url};
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17#[derive(Debug, Clone)]
19pub struct OpenApiArchiveArtifact {
20 pub path: PathBuf,
21 pub contents: String,
22 pub service_name: String,
23 pub service_version: String,
24 #[allow(dead_code)]
25 pub kind: OpenApiArtifactKind,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum OpenApiArtifactKind {
30 Service,
31 Aggregate,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct OpenApiGenerationManifest {
37 pub schema_version: u32,
38 pub updated_at: String,
39 pub repository_owner: String,
40 pub repository_name: String,
41 pub branch: String,
42 pub generations: Vec<OpenApiGenerationEntry>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct OpenApiGenerationEntry {
48 pub service_name: String,
49 pub service_version: String,
50 pub iteration: u32,
51 pub generated_at: String,
52 pub files: Vec<OpenApiGenerationFile>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub remote: Option<OpenApiGenerationRemote>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct OpenApiGenerationFile {
60 pub filename: String,
61 pub format: String,
62 pub bytes: u64,
63 pub sha256: String,
64 pub relative_path: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct OpenApiGenerationRemote {
70 pub uploaded_at: String,
71 pub keys: Vec<String>,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub public_urls: Vec<String>,
74}
75
76#[derive(Debug, Clone)]
77pub struct OpenApiArchiveResult {
78 pub global_dirs: Vec<PathBuf>,
79 pub project_dirs: Vec<PathBuf>,
80 pub uploaded: usize,
81 pub upload_skipped_reason: Option<String>,
82}
83
84pub async fn archive_and_upload_openapi_generations(
89 project_root: &Path,
90 artifacts: &[OpenApiArchiveArtifact],
91) -> Result<OpenApiArchiveResult, String> {
92 if artifacts.is_empty() {
93 return Ok(OpenApiArchiveResult {
94 global_dirs: Vec::new(),
95 project_dirs: Vec::new(),
96 uploaded: 0,
97 upload_skipped_reason: Some("no artifacts".into()),
98 });
99 }
100
101 let (owner, repo) = resolve_repo_identity(project_root);
102 let branch = resolve_git_branch(project_root).unwrap_or_else(|| "unknown".into());
103 let generated_at = Utc::now().to_rfc3339();
104
105 let mut global_dirs = Vec::new();
106 let mut project_dirs = Vec::new();
107 let mut upload_batches: Vec<(String, String, u32, Vec<OpenApiArchiveArtifact>)> = Vec::new();
108
109 let mut groups: std::collections::BTreeMap<(String, String), Vec<&OpenApiArchiveArtifact>> =
111 std::collections::BTreeMap::new();
112 for artifact in artifacts {
113 groups
114 .entry((
115 artifact.service_name.clone(),
116 artifact.service_version.clone(),
117 ))
118 .or_default()
119 .push(artifact);
120 }
121
122 for ((service_name, service_version), group) in groups {
123 let global_base = global_generation_base(&owner, &repo, &branch, &service_name, &service_version)?;
124 let project_base =
125 project_generation_base(project_root, &service_name, &service_version);
126 let iteration = next_iteration(&global_base)?
127 .max(next_iteration(&project_base)?);
128 let global_dir = global_base.join(iteration.to_string());
129 let project_dir = project_base.join(iteration.to_string());
130 fs::create_dir_all(&global_dir).map_err(|e| e.to_string())?;
131 fs::create_dir_all(&project_dir).map_err(|e| e.to_string())?;
132
133 let mut files = Vec::new();
134 let mut group_owned = Vec::new();
135 for artifact in group {
136 let filename = artifact
137 .path
138 .file_name()
139 .and_then(|n| n.to_str())
140 .unwrap_or("openapi.yaml")
141 .to_string();
142 let format = if filename.ends_with(".json") {
143 "json"
144 } else {
145 "yaml"
146 }
147 .to_string();
148 let sha = sha256_hex(artifact.contents.as_bytes());
149 let bytes = artifact.contents.len() as u64;
150
151 write_file(&global_dir.join(&filename), &artifact.contents)?;
152 write_file(&project_dir.join(&filename), &artifact.contents)?;
153
154 files.push(OpenApiGenerationFile {
155 filename: filename.clone(),
156 format,
157 bytes,
158 sha256: sha,
159 relative_path: format!(
160 "{}/{}/{}",
161 sanitize_segment(&service_name),
162 sanitize_segment(&service_version),
163 iteration
164 ),
165 });
166 group_owned.push(artifact.clone());
167 }
168
169 let entry = OpenApiGenerationEntry {
170 service_name: service_name.clone(),
171 service_version: service_version.clone(),
172 iteration,
173 generated_at: generated_at.clone(),
174 files,
175 remote: None,
176 };
177
178 upsert_manifest(
179 &global_manifest_path(&owner, &repo, &branch)?,
180 &owner,
181 &repo,
182 &branch,
183 entry.clone(),
184 )?;
185 upsert_manifest(
186 &project_manifest_path(project_root),
187 &owner,
188 &repo,
189 &branch,
190 entry,
191 )?;
192 write_readme(
193 &global_dir,
194 &owner,
195 &repo,
196 &branch,
197 &service_name,
198 &service_version,
199 iteration,
200 )?;
201 write_readme(
202 &project_dir,
203 &owner,
204 &repo,
205 &branch,
206 &service_name,
207 &service_version,
208 iteration,
209 )?;
210 write_tree_readme(
211 project_root.join(".xbp/openapi-generations"),
212 &owner,
213 &repo,
214 &branch,
215 )?;
216 write_tree_readme(
217 ensure_global_xbp_paths()?
218 .root_dir
219 .join("openapi-generations")
220 .join(sanitize_segment(&owner))
221 .join(sanitize_segment(&repo))
222 .join(sanitize_segment(&branch)),
223 &owner,
224 &repo,
225 &branch,
226 )?;
227
228 global_dirs.push(global_dir);
229 project_dirs.push(project_dir);
230 upload_batches.push((service_name, service_version, iteration, group_owned));
231 }
232
233 let mut uploaded = 0usize;
234 let mut upload_skipped_reason = None;
235 if resolve_cli_access_token().is_err() {
236 upload_skipped_reason = Some("not logged in (run `xbp login`)".into());
237 } else {
238 for (service_name, service_version, iteration, group) in upload_batches {
239 let artifacts_payload: Vec<CliOpenApiArtifactUpload> = group
240 .iter()
241 .map(|artifact| {
242 let filename = artifact
243 .path
244 .file_name()
245 .and_then(|n| n.to_str())
246 .unwrap_or("openapi.yaml")
247 .to_string();
248 let format = if filename.ends_with(".json") {
249 "json"
250 } else {
251 "yaml"
252 }
253 .to_string();
254 CliOpenApiArtifactUpload {
255 filename,
256 format,
257 content: artifact.contents.clone(),
258 content_type: if artifact.path.extension().and_then(|e| e.to_str())
259 == Some("json")
260 {
261 "application/json".into()
262 } else {
263 "application/yaml".into()
264 },
265 sha256: sha256_hex(artifact.contents.as_bytes()),
266 }
267 })
268 .collect();
269
270 let payload = CliOpenApiGenerationUploadPayload {
271 repository_owner: owner.clone(),
272 repository_name: repo.clone(),
273 branch: branch.clone(),
274 service_name: service_name.clone(),
275 service_version: service_version.clone(),
276 iteration,
277 generated_at: generated_at.clone(),
278 project_path: Some(project_root.display().to_string()),
279 artifacts: artifacts_payload,
280 };
281
282 match post_openapi_generation_upload(&payload).await {
283 Ok(Some(response)) => {
284 uploaded += response.uploaded;
285 let remote = OpenApiGenerationRemote {
286 uploaded_at: Utc::now().to_rfc3339(),
287 keys: response.keys,
288 public_urls: response.public_urls,
289 };
290 let _ = patch_manifest_remote(
292 &global_manifest_path(&owner, &repo, &branch)?,
293 &service_name,
294 &service_version,
295 iteration,
296 remote.clone(),
297 );
298 let _ = patch_manifest_remote(
299 &project_manifest_path(project_root),
300 &service_name,
301 &service_version,
302 iteration,
303 remote,
304 );
305 }
306 Ok(None) => {
307 upload_skipped_reason =
308 Some("xbp.app upload unavailable (route missing or unauthorized)".into());
309 }
310 Err(error) => {
311 upload_skipped_reason = Some(error);
312 }
313 }
314 }
315 }
316
317 Ok(OpenApiArchiveResult {
318 global_dirs,
319 project_dirs,
320 uploaded,
321 upload_skipped_reason,
322 })
323}
324
325fn resolve_repo_identity(project_root: &Path) -> (String, String) {
326 git_remote_url_from_metadata(project_root, "origin")
327 .ok()
328 .flatten()
329 .and_then(|url| parse_github_repo_from_remote_url(&url))
330 .unwrap_or_else(|| {
331 let name = project_root
332 .file_name()
333 .and_then(|n| n.to_str())
334 .unwrap_or("local")
335 .to_string();
336 ("local".into(), name)
337 })
338}
339
340fn resolve_git_branch(project_root: &Path) -> Option<String> {
341 let output = Command::new("git")
342 .args(["rev-parse", "--abbrev-ref", "HEAD"])
343 .current_dir(project_root)
344 .output()
345 .ok()?;
346 if !output.status.success() {
347 return None;
348 }
349 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
350 if branch.is_empty() || branch == "HEAD" {
351 None
352 } else {
353 Some(branch)
354 }
355}
356
357fn sanitize_segment(raw: &str) -> String {
358 let cleaned: String = raw
359 .chars()
360 .map(|c| {
361 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
362 c
363 } else {
364 '-'
365 }
366 })
367 .collect();
368 let trimmed = cleaned.trim_matches('.').trim_matches('-');
369 if trimmed.is_empty() {
370 "unknown".into()
371 } else {
372 trimmed.to_string()
373 }
374}
375
376fn global_generation_base(
377 owner: &str,
378 repo: &str,
379 branch: &str,
380 service: &str,
381 version: &str,
382) -> Result<PathBuf, String> {
383 let paths = ensure_global_xbp_paths()?;
384 Ok(paths
385 .root_dir
386 .join("openapi-generations")
387 .join(sanitize_segment(owner))
388 .join(sanitize_segment(repo))
389 .join(sanitize_segment(branch))
390 .join(sanitize_segment(service))
391 .join(sanitize_segment(version)))
392}
393
394fn project_generation_base(project_root: &Path, service: &str, version: &str) -> PathBuf {
395 project_root
396 .join(".xbp")
397 .join("openapi-generations")
398 .join(sanitize_segment(service))
399 .join(sanitize_segment(version))
400}
401
402fn global_manifest_path(owner: &str, repo: &str, branch: &str) -> Result<PathBuf, String> {
403 let paths = ensure_global_xbp_paths()?;
404 Ok(paths
405 .root_dir
406 .join("openapi-generations")
407 .join(sanitize_segment(owner))
408 .join(sanitize_segment(repo))
409 .join(sanitize_segment(branch))
410 .join("manifest.json"))
411}
412
413fn project_manifest_path(project_root: &Path) -> PathBuf {
414 project_root
415 .join(".xbp")
416 .join("openapi-generations")
417 .join("manifest.json")
418}
419
420fn next_iteration(base: &Path) -> Result<u32, String> {
421 if !base.exists() {
422 return Ok(1);
423 }
424 let mut max = 0u32;
425 for entry in fs::read_dir(base).map_err(|e| e.to_string())? {
426 let entry = entry.map_err(|e| e.to_string())?;
427 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
428 continue;
429 }
430 if let Some(n) = entry
431 .file_name()
432 .to_str()
433 .and_then(|s| s.parse::<u32>().ok())
434 {
435 max = max.max(n);
436 }
437 }
438 Ok(max.saturating_add(1).max(1))
439}
440
441fn write_file(path: &Path, contents: &str) -> Result<(), String> {
442 if let Some(parent) = path.parent() {
443 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
444 }
445 fs::write(path, contents).map_err(|e| format!("failed to write {}: {e}", path.display()))
446}
447
448fn sha256_hex(bytes: &[u8]) -> String {
449 let mut hasher = Sha256::new();
450 hasher.update(bytes);
451 format!("{:x}", hasher.finalize())
452}
453
454fn upsert_manifest(
455 path: &Path,
456 owner: &str,
457 repo: &str,
458 branch: &str,
459 entry: OpenApiGenerationEntry,
460) -> Result<(), String> {
461 let mut manifest = if path.exists() {
462 let raw = fs::read_to_string(path).map_err(|e| e.to_string())?;
463 serde_json::from_str::<OpenApiGenerationManifest>(&raw).unwrap_or(OpenApiGenerationManifest {
464 schema_version: 1,
465 updated_at: Utc::now().to_rfc3339(),
466 repository_owner: owner.to_string(),
467 repository_name: repo.to_string(),
468 branch: branch.to_string(),
469 generations: Vec::new(),
470 })
471 } else {
472 OpenApiGenerationManifest {
473 schema_version: 1,
474 updated_at: Utc::now().to_rfc3339(),
475 repository_owner: owner.to_string(),
476 repository_name: repo.to_string(),
477 branch: branch.to_string(),
478 generations: Vec::new(),
479 }
480 };
481 manifest.updated_at = Utc::now().to_rfc3339();
482 manifest.repository_owner = owner.to_string();
483 manifest.repository_name = repo.to_string();
484 manifest.branch = branch.to_string();
485 manifest.generations.insert(0, entry);
486 if manifest.generations.len() > 100 {
488 manifest.generations.truncate(100);
489 }
490 let pretty = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
491 write_file(path, &format!("{pretty}\n"))
492}
493
494fn patch_manifest_remote(
495 path: &Path,
496 service: &str,
497 version: &str,
498 iteration: u32,
499 remote: OpenApiGenerationRemote,
500) -> Result<(), String> {
501 if !path.exists() {
502 return Ok(());
503 }
504 let raw = fs::read_to_string(path).map_err(|e| e.to_string())?;
505 let mut manifest: OpenApiGenerationManifest =
506 serde_json::from_str(&raw).map_err(|e| e.to_string())?;
507 for entry in &mut manifest.generations {
508 if entry.service_name == service
509 && entry.service_version == version
510 && entry.iteration == iteration
511 {
512 entry.remote = Some(remote);
513 break;
514 }
515 }
516 manifest.updated_at = Utc::now().to_rfc3339();
517 let pretty = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
518 write_file(path, &format!("{pretty}\n"))
519}
520
521fn write_readme(
522 dir: &Path,
523 owner: &str,
524 repo: &str,
525 branch: &str,
526 service: &str,
527 version: &str,
528 iteration: u32,
529) -> Result<(), String> {
530 let body = format!(
531 "# OpenAPI generation\n\n\
532 | Field | Value |\n\
533 | --- | --- |\n\
534 | Repository | `{owner}/{repo}` |\n\
535 | Branch | `{branch}` |\n\
536 | Service | `{service}` |\n\
537 | Version | `{version}` |\n\
538 | Iteration | `{iteration}` |\n\
539 | Generated | `{}` |\n\n\
540 Files in this directory were produced by `xbp generate openapi`.\n\
541 See `manifest.json` one level up (project) or at the branch root (global) for the full index.\n",
542 Utc::now().to_rfc3339()
543 );
544 write_file(&dir.join("README.md"), &body)
545}
546
547fn write_tree_readme(dir: PathBuf, owner: &str, repo: &str, branch: &str) -> Result<(), String> {
548 fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
549 let body = format!(
550 "# OpenAPI generations\n\n\
551 Self-updating archive for `{owner}/{repo}` on `{branch}`.\n\n\
552 Layout:\n\n\
553 ```text\n\
554 {{service}}/{{version}}/{{iteration}}/openapi.{{yaml,json}}\n\
555 manifest.json\n\
556 ```\n\n\
557 Regenerated by `xbp generate openapi`. Iterations increment automatically when the same\n\
558 service version is generated again.\n\n\
559 Last updated: {}\n",
560 Utc::now().to_rfc3339()
561 );
562 write_file(&dir.join("README.md"), &body)
563}
564
565#[allow(dead_code)]
567fn _api_hint() -> String {
568 ApiConfig::load().cli_openapi_upload_endpoint()
569}
570
571#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[test]
577 fn sanitizes_path_segments() {
578 assert_eq!(sanitize_segment("athena-auth"), "athena-auth");
579 assert_eq!(sanitize_segment("feat/foo"), "feat-foo");
580 assert_eq!(sanitize_segment("../x"), "x");
581 assert_eq!(sanitize_segment(""), "unknown");
582 }
583
584 #[test]
585 fn iteration_starts_at_one() {
586 let dir = std::env::temp_dir().join(format!(
587 "xbp-openapi-iter-{}",
588 Utc::now().timestamp_nanos_opt().unwrap_or(0)
589 ));
590 let _ = fs::remove_dir_all(&dir);
591 assert_eq!(next_iteration(&dir).unwrap(), 1);
592 fs::create_dir_all(dir.join("1")).unwrap();
593 fs::create_dir_all(dir.join("2")).unwrap();
594 assert_eq!(next_iteration(&dir).unwrap(), 3);
595 let _ = fs::remove_dir_all(&dir);
596 }
597}