Skip to main content

xbp_cli/commands/
openapi_archive.rs

1//! Archive generated OpenAPI contracts under global and project `.xbp` trees,
2//! and optionally upload them to xbp.app (R2-backed).
3
4use 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/// One generated OpenAPI artifact ready to archive / upload.
18#[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
50/// Manifest schema version for richer generation metadata (CLI version, route counts).
51const 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    /// XBP CLI version that last wrote this manifest.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub xbp_cli_version: Option<String>,
64    /// Rolling summary across retained generation entries.
65    #[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    /// `service` or `aggregate`.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub kind: Option<String>,
98    /// XBP CLI that produced this generation.
99    #[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
149/// Persist artifacts under:
150/// - `~/.xbp/openapi-generations/{owner}/{repo}/{branch}/{service}/{version}/{iteration}/`
151/// - `{project}/.xbp/openapi-generations/{service}/{version}/{iteration}/`
152/// and best-effort upload to xbp.app when logged in.
153pub 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    // Group by service + version so iterations stay coherent across yaml/json.
175    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                    // Patch manifests with remote info (best-effort).
398                    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    // Keep the last 100 generations in the manifest for readability.
590    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// Keep ApiConfig import used when feature builds expand upload endpoints.
757#[allow(dead_code)]
758fn _api_hint() -> String {
759    ApiConfig::load().cli_openapi_upload_endpoint()
760}
761
762// Re-export for unit tests of segment sanitization without full archive.
763#[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}