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 pub kind: OpenApiArtifactKind,
25 pub xbp_cli_version: String,
26 pub path_count: u32,
27 pub operation_count: u32,
28 pub schema_count: u32,
29 pub server_count: u32,
30 pub websocket_operation_count: u32,
31 pub openapi_dialect: String,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub enum OpenApiArtifactKind {
37 Service,
38 Aggregate,
39}
40
41impl OpenApiArtifactKind {
42 pub fn as_str(self) -> &'static str {
43 match self {
44 Self::Service => "service",
45 Self::Aggregate => "aggregate",
46 }
47 }
48}
49
50const MANIFEST_SCHEMA_VERSION: u32 = 2;
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct OpenApiGenerationManifest {
56 pub schema_version: u32,
57 pub updated_at: String,
58 pub repository_owner: String,
59 pub repository_name: String,
60 pub branch: String,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub xbp_cli_version: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub summary: Option<OpenApiManifestSummary>,
67 pub generations: Vec<OpenApiGenerationEntry>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct OpenApiManifestSummary {
73 pub generation_count: usize,
74 pub unique_services: usize,
75 pub total_files: usize,
76 pub total_bytes: u64,
77 pub total_paths: u64,
78 pub total_operations: u64,
79 pub total_schemas: u64,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub latest_generated_at: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub latest_service: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub latest_xbp_cli_version: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct OpenApiGenerationEntry {
91 pub service_name: String,
92 pub service_version: String,
93 pub iteration: u32,
94 pub generated_at: String,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub kind: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub xbp_cli_version: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub openapi_dialect: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub stats: Option<OpenApiGenerationStats>,
105 pub files: Vec<OpenApiGenerationFile>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub remote: Option<OpenApiGenerationRemote>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct OpenApiGenerationStats {
113 pub path_count: u32,
114 pub operation_count: u32,
115 pub schema_count: u32,
116 pub server_count: u32,
117 pub websocket_operation_count: u32,
118 pub file_count: u32,
119 pub total_bytes: u64,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct OpenApiGenerationFile {
125 pub filename: String,
126 pub format: String,
127 pub bytes: u64,
128 pub sha256: String,
129 pub relative_path: String,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct OpenApiGenerationRemote {
135 pub uploaded_at: String,
136 pub keys: Vec<String>,
137 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub public_urls: Vec<String>,
139}
140
141#[derive(Debug, Clone)]
142pub struct OpenApiArchiveResult {
143 pub global_dirs: Vec<PathBuf>,
144 pub project_dirs: Vec<PathBuf>,
145 pub uploaded: usize,
146 pub upload_skipped_reason: Option<String>,
147}
148
149pub async fn archive_and_upload_openapi_generations(
154 project_root: &Path,
155 artifacts: &[OpenApiArchiveArtifact],
156) -> Result<OpenApiArchiveResult, String> {
157 if artifacts.is_empty() {
158 return Ok(OpenApiArchiveResult {
159 global_dirs: Vec::new(),
160 project_dirs: Vec::new(),
161 uploaded: 0,
162 upload_skipped_reason: Some("no artifacts".into()),
163 });
164 }
165
166 let (owner, repo) = resolve_repo_identity(project_root);
167 let branch = resolve_git_branch(project_root).unwrap_or_else(|| "unknown".into());
168 let generated_at = Utc::now().to_rfc3339();
169
170 let mut global_dirs = Vec::new();
171 let mut project_dirs = Vec::new();
172 let mut upload_batches: Vec<(String, String, u32, Vec<OpenApiArchiveArtifact>)> = Vec::new();
173
174 let mut groups: std::collections::BTreeMap<(String, String), Vec<&OpenApiArchiveArtifact>> =
176 std::collections::BTreeMap::new();
177 for artifact in artifacts {
178 groups
179 .entry((
180 artifact.service_name.clone(),
181 artifact.service_version.clone(),
182 ))
183 .or_default()
184 .push(artifact);
185 }
186
187 for ((service_name, service_version), group) in groups {
188 let global_base = global_generation_base(&owner, &repo, &branch, &service_name, &service_version)?;
189 let project_base =
190 project_generation_base(project_root, &service_name, &service_version);
191 let iteration = next_iteration(&global_base)?
192 .max(next_iteration(&project_base)?);
193 let global_dir = global_base.join(iteration.to_string());
194 let project_dir = project_base.join(iteration.to_string());
195 fs::create_dir_all(&global_dir).map_err(|e| e.to_string())?;
196 fs::create_dir_all(&project_dir).map_err(|e| e.to_string())?;
197
198 let mut files = Vec::new();
199 let mut group_owned = Vec::new();
200 let mut total_bytes = 0u64;
201 let first = group.first().copied();
202 let kind = first.map(|a| a.kind).unwrap_or(OpenApiArtifactKind::Service);
203 let xbp_cli_version = first
204 .map(|a| a.xbp_cli_version.clone())
205 .unwrap_or_else(|| "unknown".into());
206 let openapi_dialect = first
207 .map(|a| a.openapi_dialect.clone())
208 .unwrap_or_default();
209 let path_count = first.map(|a| a.path_count).unwrap_or(0);
210 let operation_count = first.map(|a| a.operation_count).unwrap_or(0);
211 let schema_count = first.map(|a| a.schema_count).unwrap_or(0);
212 let server_count = first.map(|a| a.server_count).unwrap_or(0);
213 let websocket_operation_count = first.map(|a| a.websocket_operation_count).unwrap_or(0);
214
215 for artifact in group {
216 let filename = artifact
217 .path
218 .file_name()
219 .and_then(|n| n.to_str())
220 .unwrap_or("openapi.yaml")
221 .to_string();
222 let format = if filename.ends_with(".json") {
223 "json"
224 } else {
225 "yaml"
226 }
227 .to_string();
228 let sha = sha256_hex(artifact.contents.as_bytes());
229 let bytes = artifact.contents.len() as u64;
230 total_bytes = total_bytes.saturating_add(bytes);
231
232 write_file(&global_dir.join(&filename), &artifact.contents)?;
233 write_file(&project_dir.join(&filename), &artifact.contents)?;
234
235 files.push(OpenApiGenerationFile {
236 filename: filename.clone(),
237 format,
238 bytes,
239 sha256: sha,
240 relative_path: format!(
241 "{}/{}/{}",
242 sanitize_segment(&service_name),
243 sanitize_segment(&service_version),
244 iteration
245 ),
246 });
247 group_owned.push(artifact.clone());
248 }
249
250 let stats = OpenApiGenerationStats {
251 path_count,
252 operation_count,
253 schema_count,
254 server_count,
255 websocket_operation_count,
256 file_count: files.len() as u32,
257 total_bytes,
258 };
259
260 let entry = OpenApiGenerationEntry {
261 service_name: service_name.clone(),
262 service_version: service_version.clone(),
263 iteration,
264 generated_at: generated_at.clone(),
265 kind: Some(kind.as_str().to_string()),
266 xbp_cli_version: Some(xbp_cli_version.clone()),
267 openapi_dialect: (!openapi_dialect.is_empty()).then_some(openapi_dialect.clone()),
268 stats: Some(stats.clone()),
269 files,
270 remote: None,
271 };
272
273 upsert_manifest(
274 &global_manifest_path(&owner, &repo, &branch)?,
275 &owner,
276 &repo,
277 &branch,
278 &xbp_cli_version,
279 entry.clone(),
280 )?;
281 upsert_manifest(
282 &project_manifest_path(project_root),
283 &owner,
284 &repo,
285 &branch,
286 &xbp_cli_version,
287 entry,
288 )?;
289 write_readme(
290 &global_dir,
291 &owner,
292 &repo,
293 &branch,
294 &service_name,
295 &service_version,
296 iteration,
297 &xbp_cli_version,
298 kind,
299 &stats,
300 openapi_dialect.as_str(),
301 )?;
302 write_readme(
303 &project_dir,
304 &owner,
305 &repo,
306 &branch,
307 &service_name,
308 &service_version,
309 iteration,
310 &xbp_cli_version,
311 kind,
312 &stats,
313 openapi_dialect.as_str(),
314 )?;
315 write_tree_readme(
316 project_root.join(".xbp/openapi-generations"),
317 &owner,
318 &repo,
319 &branch,
320 &xbp_cli_version,
321 )?;
322 write_tree_readme(
323 ensure_global_xbp_paths()?
324 .root_dir
325 .join("openapi-generations")
326 .join(sanitize_segment(&owner))
327 .join(sanitize_segment(&repo))
328 .join(sanitize_segment(&branch)),
329 &owner,
330 &repo,
331 &branch,
332 &xbp_cli_version,
333 )?;
334
335 global_dirs.push(global_dir);
336 project_dirs.push(project_dir);
337 upload_batches.push((service_name, service_version, iteration, group_owned));
338 }
339
340 let mut uploaded = 0usize;
341 let mut upload_skipped_reason = None;
342 if resolve_cli_access_token().is_err() {
343 upload_skipped_reason = Some("not logged in (run `xbp login`)".into());
344 } else {
345 for (service_name, service_version, iteration, group) in upload_batches {
346 let artifacts_payload: Vec<CliOpenApiArtifactUpload> = group
347 .iter()
348 .map(|artifact| {
349 let filename = artifact
350 .path
351 .file_name()
352 .and_then(|n| n.to_str())
353 .unwrap_or("openapi.yaml")
354 .to_string();
355 let format = if filename.ends_with(".json") {
356 "json"
357 } else {
358 "yaml"
359 }
360 .to_string();
361 CliOpenApiArtifactUpload {
362 filename,
363 format,
364 content: artifact.contents.clone(),
365 content_type: if artifact.path.extension().and_then(|e| e.to_str())
366 == Some("json")
367 {
368 "application/json".into()
369 } else {
370 "application/yaml".into()
371 },
372 sha256: sha256_hex(artifact.contents.as_bytes()),
373 }
374 })
375 .collect();
376
377 let payload = CliOpenApiGenerationUploadPayload {
378 repository_owner: owner.clone(),
379 repository_name: repo.clone(),
380 branch: branch.clone(),
381 service_name: service_name.clone(),
382 service_version: service_version.clone(),
383 iteration,
384 generated_at: generated_at.clone(),
385 project_path: Some(project_root.display().to_string()),
386 artifacts: artifacts_payload,
387 };
388
389 match post_openapi_generation_upload(&payload).await {
390 Ok(Some(response)) => {
391 uploaded += response.uploaded;
392 let remote = OpenApiGenerationRemote {
393 uploaded_at: Utc::now().to_rfc3339(),
394 keys: response.keys.clone(),
395 public_urls: response.public_urls.clone(),
396 };
397 let _ = patch_manifest_remote(
399 &global_manifest_path(&owner, &repo, &branch)?,
400 &service_name,
401 &service_version,
402 iteration,
403 remote.clone(),
404 );
405 let _ = patch_manifest_remote(
406 &project_manifest_path(project_root),
407 &service_name,
408 &service_version,
409 iteration,
410 remote,
411 );
412 for url in &response.public_urls {
413 if !url.trim().is_empty() {
414 println!("public {}", url);
415 }
416 }
417 }
418 Ok(None) => {
419 upload_skipped_reason =
420 Some("xbp.app upload unavailable (route missing or unauthorized)".into());
421 }
422 Err(error) => {
423 upload_skipped_reason = Some(error);
424 }
425 }
426 }
427 }
428
429 Ok(OpenApiArchiveResult {
430 global_dirs,
431 project_dirs,
432 uploaded,
433 upload_skipped_reason,
434 })
435}
436
437fn resolve_repo_identity(project_root: &Path) -> (String, String) {
438 git_remote_url_from_metadata(project_root, "origin")
439 .ok()
440 .flatten()
441 .and_then(|url| parse_github_repo_from_remote_url(&url))
442 .unwrap_or_else(|| {
443 let name = project_root
444 .file_name()
445 .and_then(|n| n.to_str())
446 .unwrap_or("local")
447 .to_string();
448 ("local".into(), name)
449 })
450}
451
452fn resolve_git_branch(project_root: &Path) -> Option<String> {
453 let output = Command::new("git")
454 .args(["rev-parse", "--abbrev-ref", "HEAD"])
455 .current_dir(project_root)
456 .output()
457 .ok()?;
458 if !output.status.success() {
459 return None;
460 }
461 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
462 if branch.is_empty() || branch == "HEAD" {
463 None
464 } else {
465 Some(branch)
466 }
467}
468
469fn sanitize_segment(raw: &str) -> String {
470 let cleaned: String = raw
471 .chars()
472 .map(|c| {
473 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
474 c
475 } else {
476 '-'
477 }
478 })
479 .collect();
480 let trimmed = cleaned.trim_matches('.').trim_matches('-');
481 if trimmed.is_empty() {
482 "unknown".into()
483 } else {
484 trimmed.to_string()
485 }
486}
487
488fn global_generation_base(
489 owner: &str,
490 repo: &str,
491 branch: &str,
492 service: &str,
493 version: &str,
494) -> Result<PathBuf, String> {
495 let paths = ensure_global_xbp_paths()?;
496 Ok(paths
497 .root_dir
498 .join("openapi-generations")
499 .join(sanitize_segment(owner))
500 .join(sanitize_segment(repo))
501 .join(sanitize_segment(branch))
502 .join(sanitize_segment(service))
503 .join(sanitize_segment(version)))
504}
505
506fn project_generation_base(project_root: &Path, service: &str, version: &str) -> PathBuf {
507 project_root
508 .join(".xbp")
509 .join("openapi-generations")
510 .join(sanitize_segment(service))
511 .join(sanitize_segment(version))
512}
513
514fn global_manifest_path(owner: &str, repo: &str, branch: &str) -> Result<PathBuf, String> {
515 let paths = ensure_global_xbp_paths()?;
516 Ok(paths
517 .root_dir
518 .join("openapi-generations")
519 .join(sanitize_segment(owner))
520 .join(sanitize_segment(repo))
521 .join(sanitize_segment(branch))
522 .join("manifest.json"))
523}
524
525fn project_manifest_path(project_root: &Path) -> PathBuf {
526 project_root
527 .join(".xbp")
528 .join("openapi-generations")
529 .join("manifest.json")
530}
531
532fn next_iteration(base: &Path) -> Result<u32, String> {
533 if !base.exists() {
534 return Ok(1);
535 }
536 let mut max = 0u32;
537 for entry in fs::read_dir(base).map_err(|e| e.to_string())? {
538 let entry = entry.map_err(|e| e.to_string())?;
539 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
540 continue;
541 }
542 if let Some(n) = entry
543 .file_name()
544 .to_str()
545 .and_then(|s| s.parse::<u32>().ok())
546 {
547 max = max.max(n);
548 }
549 }
550 Ok(max.saturating_add(1).max(1))
551}
552
553fn write_file(path: &Path, contents: &str) -> Result<(), String> {
554 if let Some(parent) = path.parent() {
555 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
556 }
557 fs::write(path, contents).map_err(|e| format!("failed to write {}: {e}", path.display()))
558}
559
560fn sha256_hex(bytes: &[u8]) -> String {
561 let mut hasher = Sha256::new();
562 hasher.update(bytes);
563 format!("{:x}", hasher.finalize())
564}
565
566fn upsert_manifest(
567 path: &Path,
568 owner: &str,
569 repo: &str,
570 branch: &str,
571 xbp_cli_version: &str,
572 entry: OpenApiGenerationEntry,
573) -> Result<(), String> {
574 let mut manifest = if path.exists() {
575 let raw = fs::read_to_string(path).map_err(|e| e.to_string())?;
576 serde_json::from_str::<OpenApiGenerationManifest>(&raw).unwrap_or_else(|_| {
577 empty_manifest(owner, repo, branch, xbp_cli_version)
578 })
579 } else {
580 empty_manifest(owner, repo, branch, xbp_cli_version)
581 };
582 manifest.schema_version = MANIFEST_SCHEMA_VERSION;
583 manifest.updated_at = Utc::now().to_rfc3339();
584 manifest.repository_owner = owner.to_string();
585 manifest.repository_name = repo.to_string();
586 manifest.branch = branch.to_string();
587 manifest.xbp_cli_version = Some(xbp_cli_version.to_string());
588 manifest.generations.insert(0, entry);
589 if manifest.generations.len() > 100 {
591 manifest.generations.truncate(100);
592 }
593 manifest.summary = Some(compute_manifest_summary(&manifest.generations));
594 let pretty = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
595 write_file(path, &format!("{pretty}\n"))
596}
597
598fn empty_manifest(
599 owner: &str,
600 repo: &str,
601 branch: &str,
602 xbp_cli_version: &str,
603) -> OpenApiGenerationManifest {
604 OpenApiGenerationManifest {
605 schema_version: MANIFEST_SCHEMA_VERSION,
606 updated_at: Utc::now().to_rfc3339(),
607 repository_owner: owner.to_string(),
608 repository_name: repo.to_string(),
609 branch: branch.to_string(),
610 xbp_cli_version: Some(xbp_cli_version.to_string()),
611 summary: None,
612 generations: Vec::new(),
613 }
614}
615
616fn compute_manifest_summary(generations: &[OpenApiGenerationEntry]) -> OpenApiManifestSummary {
617 let mut unique_services = std::collections::BTreeSet::new();
618 let mut total_files = 0usize;
619 let mut total_bytes = 0u64;
620 let mut total_paths = 0u64;
621 let mut total_operations = 0u64;
622 let mut total_schemas = 0u64;
623 for entry in generations {
624 unique_services.insert(entry.service_name.as_str());
625 total_files = total_files.saturating_add(entry.files.len());
626 for file in &entry.files {
627 total_bytes = total_bytes.saturating_add(file.bytes);
628 }
629 if let Some(stats) = &entry.stats {
630 total_paths = total_paths.saturating_add(u64::from(stats.path_count));
631 total_operations = total_operations.saturating_add(u64::from(stats.operation_count));
632 total_schemas = total_schemas.saturating_add(u64::from(stats.schema_count));
633 }
634 }
635 let latest = generations.first();
636 OpenApiManifestSummary {
637 generation_count: generations.len(),
638 unique_services: unique_services.len(),
639 total_files,
640 total_bytes,
641 total_paths,
642 total_operations,
643 total_schemas,
644 latest_generated_at: latest.map(|e| e.generated_at.clone()),
645 latest_service: latest.map(|e| e.service_name.clone()),
646 latest_xbp_cli_version: latest.and_then(|e| e.xbp_cli_version.clone()),
647 }
648}
649
650fn patch_manifest_remote(
651 path: &Path,
652 service: &str,
653 version: &str,
654 iteration: u32,
655 remote: OpenApiGenerationRemote,
656) -> Result<(), String> {
657 if !path.exists() {
658 return Ok(());
659 }
660 let raw = fs::read_to_string(path).map_err(|e| e.to_string())?;
661 let mut manifest: OpenApiGenerationManifest =
662 serde_json::from_str(&raw).map_err(|e| e.to_string())?;
663 for entry in &mut manifest.generations {
664 if entry.service_name == service
665 && entry.service_version == version
666 && entry.iteration == iteration
667 {
668 entry.remote = Some(remote);
669 break;
670 }
671 }
672 manifest.updated_at = Utc::now().to_rfc3339();
673 let pretty = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
674 write_file(path, &format!("{pretty}\n"))
675}
676
677fn write_readme(
678 dir: &Path,
679 owner: &str,
680 repo: &str,
681 branch: &str,
682 service: &str,
683 version: &str,
684 iteration: u32,
685 xbp_cli_version: &str,
686 kind: OpenApiArtifactKind,
687 stats: &OpenApiGenerationStats,
688 openapi_dialect: &str,
689) -> Result<(), String> {
690 let dialect_row = if openapi_dialect.is_empty() {
691 String::new()
692 } else {
693 format!("| OpenAPI dialect | `{openapi_dialect}` |\n")
694 };
695 let body = format!(
696 "# OpenAPI generation\n\n\
697 | Field | Value |\n\
698 | --- | --- |\n\
699 | Repository | `{owner}/{repo}` |\n\
700 | Branch | `{branch}` |\n\
701 | Service | `{service}` |\n\
702 | Kind | `{}` |\n\
703 | API version | `{version}` |\n\
704 | Iteration | `{iteration}` |\n\
705 | **XBP CLI version** | **`v{xbp_cli_version}`** |\n\
706 {dialect_row}\
707 | Paths | `{}` |\n\
708 | Operations | `{}` |\n\
709 | Schemas | `{}` |\n\
710 | Servers | `{}` |\n\
711 | WebSocket ops | `{}` |\n\
712 | Files | `{}` (`{}` bytes) |\n\
713 | Generated | `{}` |\n\n\
714 Files in this directory were produced by **xbp CLI v{xbp_cli_version}** via \
715 `xbp generate openapi`.\n\
716 See `manifest.json` one level up (project) or at the branch root (global) for the full index.\n",
717 kind.as_str(),
718 stats.path_count,
719 stats.operation_count,
720 stats.schema_count,
721 stats.server_count,
722 stats.websocket_operation_count,
723 stats.file_count,
724 stats.total_bytes,
725 Utc::now().to_rfc3339()
726 );
727 write_file(&dir.join("README.md"), &body)
728}
729
730fn write_tree_readme(
731 dir: PathBuf,
732 owner: &str,
733 repo: &str,
734 branch: &str,
735 xbp_cli_version: &str,
736) -> Result<(), String> {
737 fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
738 let body = format!(
739 "# OpenAPI generations\n\n\
740 Self-updating archive for `{owner}/{repo}` on `{branch}`.\n\n\
741 Last written by **xbp CLI v{xbp_cli_version}** (`xbp generate openapi`).\n\n\
742 Layout:\n\n\
743 ```text\n\
744 {{service}}/{{version}}/{{iteration}}/openapi.{{yaml,json}}\n\
745 manifest.json\n\
746 ```\n\n\
747 Each generation entry in `manifest.json` records the XBP CLI version, path/operation/\n\
748 schema counts, file digests, and optional remote upload keys.\n\
749 Iterations increment automatically when the same service version is generated again.\n\n\
750 Last updated: {}\n",
751 Utc::now().to_rfc3339()
752 );
753 write_file(&dir.join("README.md"), &body)
754}
755
756#[allow(dead_code)]
758fn _api_hint() -> String {
759 ApiConfig::load().cli_openapi_upload_endpoint()
760}
761
762#[cfg(test)]
764mod tests {
765 use super::*;
766
767 #[test]
768 fn sanitizes_path_segments() {
769 assert_eq!(sanitize_segment("athena-auth"), "athena-auth");
770 assert_eq!(sanitize_segment("feat/foo"), "feat-foo");
771 assert_eq!(sanitize_segment("../x"), "x");
772 assert_eq!(sanitize_segment(""), "unknown");
773 }
774
775 #[test]
776 fn iteration_starts_at_one() {
777 let dir = std::env::temp_dir().join(format!(
778 "xbp-openapi-iter-{}",
779 Utc::now().timestamp_nanos_opt().unwrap_or(0)
780 ));
781 let _ = fs::remove_dir_all(&dir);
782 assert_eq!(next_iteration(&dir).unwrap(), 1);
783 fs::create_dir_all(dir.join("1")).unwrap();
784 fs::create_dir_all(dir.join("2")).unwrap();
785 assert_eq!(next_iteration(&dir).unwrap(), 3);
786 let _ = fs::remove_dir_all(&dir);
787 }
788
789 #[test]
790 fn manifest_summary_aggregates_route_and_file_stats() {
791 let summary = compute_manifest_summary(&[OpenApiGenerationEntry {
792 service_name: "api".into(),
793 service_version: "1.0.0".into(),
794 iteration: 1,
795 generated_at: "2026-01-01T00:00:00Z".into(),
796 kind: Some("service".into()),
797 xbp_cli_version: Some("10.37.3".into()),
798 openapi_dialect: Some("3.1.0".into()),
799 stats: Some(OpenApiGenerationStats {
800 path_count: 4,
801 operation_count: 7,
802 schema_count: 12,
803 server_count: 2,
804 websocket_operation_count: 1,
805 file_count: 2,
806 total_bytes: 900,
807 }),
808 files: vec![
809 OpenApiGenerationFile {
810 filename: "openapi.yaml".into(),
811 format: "yaml".into(),
812 bytes: 500,
813 sha256: "a".into(),
814 relative_path: "api/1.0.0/1".into(),
815 },
816 OpenApiGenerationFile {
817 filename: "openapi.json".into(),
818 format: "json".into(),
819 bytes: 400,
820 sha256: "b".into(),
821 relative_path: "api/1.0.0/1".into(),
822 },
823 ],
824 remote: None,
825 }]);
826 assert_eq!(summary.generation_count, 1);
827 assert_eq!(summary.unique_services, 1);
828 assert_eq!(summary.total_files, 2);
829 assert_eq!(summary.total_bytes, 900);
830 assert_eq!(summary.total_paths, 4);
831 assert_eq!(summary.total_operations, 7);
832 assert_eq!(summary.total_schemas, 12);
833 assert_eq!(summary.latest_xbp_cli_version.as_deref(), Some("10.37.3"));
834 }
835}