Skip to main content

resq_cli/commands/
docs.rs

1/*
2 * Copyright 2026 ResQ
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17use anyhow::{anyhow, Context};
18use base64::Engine;
19use clap::Args;
20use serde_json::Value;
21use std::path::Path;
22use std::process::Command;
23
24/// Arguments for the `docs` command.
25#[derive(Args)]
26pub struct DocsArgs {
27    /// Only export the specifications locally without publishing
28    #[arg(short, long)]
29    pub export_only: bool,
30
31    /// Publish the specifications to the documentation repository
32    #[arg(short, long)]
33    pub publish: bool,
34
35    /// Dry run: show what would be done without executing
36    #[arg(long)]
37    pub dry_run: bool,
38}
39
40/// Run the documentation export and publication process.
41///
42/// # Errors
43/// Returns an error if any of the export or publication steps fail,
44/// or if there are issues accessing the file system or GitHub API.
45pub fn run(args: DocsArgs) -> anyhow::Result<()> {
46    let root_dir = crate::utils::find_project_root();
47
48    // 1. Export Infrastructure API Spec
49    header("Exporting Infrastructure API spec");
50    export_infrastructure(&root_dir, &args)?;
51
52    // 2. Export Coordination HCE Spec
53    header("Exporting Coordination HCE spec");
54    export_coordination(&root_dir, &args)?;
55
56    if args.publish && !args.export_only {
57        header("Publishing specifications to GitHub");
58        publish_spec(
59            &root_dir,
60            "infrastructure.json",
61            "specs/infrastructure.json",
62            &args,
63        )?;
64        publish_spec(
65            &root_dir,
66            "coordination.json",
67            "specs/coordination.json",
68            &args,
69        )?;
70    }
71
72    Ok(())
73}
74
75fn export_infrastructure(root: &Path, args: &DocsArgs) -> anyhow::Result<()> {
76    let output_path = root.join("../docs/specs/infrastructure.json");
77
78    if args.dry_run {
79        println!(
80            "[Dry Run] Would run: cargo run -p resq-api --bin export_openapi {}",
81            output_path.display()
82        );
83        return Ok(());
84    }
85
86    let output = Command::new("cargo")
87        .args([
88            "run",
89            "-q",
90            "-p",
91            "resq-api",
92            "--bin",
93            "export_openapi",
94            "--",
95            output_path
96                .to_str()
97                .context("Output path contains invalid UTF-8")?,
98        ])
99        .current_dir(root.join("services/infrastructure-api"))
100        .output()
101        .context("Failed to execute infrastructure export")?;
102
103    if !output.status.success() {
104        return Err(anyhow!(
105            "Infrastructure export failed:\n{}",
106            String::from_utf8_lossy(&output.stderr)
107        ));
108    }
109
110    println!(
111        "✓ Infrastructure API spec exported to {}",
112        output_path.display()
113    );
114
115    Ok(())
116}
117
118fn export_coordination(root: &Path, args: &DocsArgs) -> anyhow::Result<()> {
119    let output_path = root.join("../docs/specs/coordination.json");
120
121    if args.dry_run {
122        println!(
123            "[Dry Run] Would run: bun run export-openapi.ts {}",
124            output_path.display()
125        );
126        return Ok(());
127    }
128
129    let output = Command::new("bun")
130        .args([
131            "run",
132            "export-openapi.ts",
133            output_path
134                .to_str()
135                .context("Output path contains invalid UTF-8")?,
136        ])
137        .current_dir(root.join("services/coordination-hce"))
138        .output()
139        .context("Failed to execute coordination export")?;
140
141    if !output.status.success() {
142        return Err(anyhow!(
143            "Coordination export failed:\n{}",
144            String::from_utf8_lossy(&output.stderr)
145        ));
146    }
147
148    println!(
149        "✓ Coordination HCE spec exported to {}",
150        output_path.display()
151    );
152
153    Ok(())
154}
155
156fn publish_spec(
157    root: &Path,
158    local_filename: &str,
159    remote_path: &str,
160    args: &DocsArgs,
161) -> anyhow::Result<()> {
162    let repo = "resq-software/docs";
163    let local_path = root.join("../docs/specs").join(local_filename);
164
165    if args.dry_run {
166        println!(
167            "[Dry Run] Would publish {} to {} as {}",
168            local_path.display(),
169            repo,
170            remote_path
171        );
172        return Ok(());
173    }
174
175    // Read local file
176    let content = std::fs::read_to_string(&local_path)
177        .with_context(|| format!("Failed to read local spec: {}", local_path.display()))?;
178
179    let base64_content = base64::engine::general_purpose::STANDARD.encode(content);
180
181    // Get current SHA
182    let repo_url = format!("repos/{repo}/contents/{remote_path}");
183    let output = Command::new("gh")
184        .args(["api", &repo_url])
185        .output()
186        .context("Failed to get existing file metadata from GitHub")?;
187
188    let sha = if output.status.success() {
189        let json: Value = serde_json::from_slice(&output.stdout)?;
190        json["sha"].as_str().map(ToString::to_string)
191    } else {
192        None
193    };
194
195    // Prepare update command
196    let message_arg = format!("message=docs: update {local_filename} [skip ci]");
197    let content_arg = format!("content={base64_content}");
198
199    let mut gh_args = vec![
200        "api",
201        "--method",
202        "PUT",
203        &repo_url,
204        "-f",
205        &message_arg,
206        "-f",
207        &content_arg,
208    ];
209
210    let sha_arg; // Must declare outside to live long enough
211    if let Some(sha_val) = sha {
212        gh_args.push("-f");
213        sha_arg = format!("sha={sha_val}");
214        gh_args.push(&sha_arg);
215    }
216
217    let put_output = Command::new("gh")
218        .args(&gh_args)
219        .output()
220        .context("Failed to publish content to GitHub")?;
221
222    if !put_output.status.success() {
223        return Err(anyhow!(
224            "Failed to publish {local_filename} to GitHub:\n{}",
225            String::from_utf8_lossy(&put_output.stderr)
226        ));
227    }
228
229    println!("✓ Successfully published {local_filename} to {repo}");
230    Ok(())
231}
232
233fn header(title: &str) {
234    let bar = "━".repeat(74usize.saturating_sub(title.len() + 1));
235    println!("\n━━━ {title} {bar}");
236}