resq_cli/commands/
docs.rs1use anyhow::{anyhow, Context};
18use base64::Engine;
19use clap::Args;
20use serde_json::Value;
21use std::path::Path;
22use std::process::Command;
23
24#[derive(Args)]
26pub struct DocsArgs {
27 #[arg(short, long)]
29 pub export_only: bool,
30
31 #[arg(short, long)]
33 pub publish: bool,
34
35 #[arg(long)]
37 pub dry_run: bool,
38}
39
40pub fn run(args: DocsArgs) -> anyhow::Result<()> {
46 let root_dir = crate::utils::find_project_root();
47
48 header("Exporting Infrastructure API spec");
50 export_infrastructure(&root_dir, &args)?;
51
52 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 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 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 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; 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}