1use anyhow::{bail, Context, Result};
20use std::path::Path;
21use std::process::{Command, Stdio};
22
23const OPENAPI_SOURCE: &str = "docs/generated/openapi.json";
27
28struct CrateEntry {
30 name: String,
32 doc_dir: String,
35 description: String,
37}
38
39pub(crate) fn emit(doc_root: &Path) -> Result<()> {
48 let entries = workspace_crates()?;
49 std::fs::create_dir_all(doc_root)
50 .with_context(|| format!("failed to create {}", doc_root.display()))?;
51
52 let index_path = doc_root.join("index.html");
53 std::fs::write(&index_path, landing_html(&entries))
54 .with_context(|| format!("failed to write {}", index_path.display()))?;
55 println!("==> Wrote landing page {}", index_path.display());
56
57 let openapi_source = Path::new(OPENAPI_SOURCE);
58 if openapi_source.is_file() {
59 let spec_path = doc_root.join("openapi.json");
60 std::fs::copy(openapi_source, &spec_path)
61 .with_context(|| format!("failed to copy {OPENAPI_SOURCE} to doc root"))?;
62 let redoc_path = doc_root.join("openapi.html");
63 std::fs::write(&redoc_path, REDOC_HTML)
64 .with_context(|| format!("failed to write {}", redoc_path.display()))?;
65 println!("==> Wrote OpenAPI page {}", redoc_path.display());
66 } else {
67 println!("==> Skipped OpenAPI page: {OPENAPI_SOURCE} not found");
70 }
71 Ok(())
72}
73
74fn workspace_crates() -> Result<Vec<CrateEntry>> {
81 let output = Command::new("cargo")
82 .args(["metadata", "--format-version", "1", "--no-deps", "--locked"])
83 .stdin(Stdio::null())
84 .output()
85 .context("Failed to spawn `cargo metadata`")?;
86 if !output.status.success() {
87 bail!("`cargo metadata` exited with status {}", output.status);
88 }
89 let metadata: serde_json::Value =
90 serde_json::from_slice(&output.stdout).context("`cargo metadata` emitted invalid JSON")?;
91 let packages = metadata
92 .get("packages")
93 .and_then(|value| value.as_array())
94 .context("`cargo metadata` output has no `packages` array")?;
95
96 let mut entries = Vec::new();
97 for package in packages {
98 let Some(name) = package.get("name").and_then(|value| value.as_str()) else {
99 continue;
100 };
101 let Some(doc_target) = documented_target_name(package) else {
102 continue;
104 };
105 let description = package
106 .get("description")
107 .and_then(|value| value.as_str())
108 .unwrap_or("")
109 .to_owned();
110 entries.push(CrateEntry {
111 name: name.to_owned(),
112 doc_dir: doc_target.replace('-', "_"),
113 description,
114 });
115 }
116 entries.sort_by(|a, b| a.name.cmp(&b.name));
117 Ok(entries)
118}
119
120fn documented_target_name(package: &serde_json::Value) -> Option<String> {
125 let targets = package.get("targets")?.as_array()?;
126 let kind_of = |target: &serde_json::Value| -> Vec<String> {
127 target
128 .get("kind")
129 .and_then(|value| value.as_array())
130 .map(|kinds| {
131 kinds
132 .iter()
133 .filter_map(|kind| kind.as_str().map(str::to_owned))
134 .collect()
135 })
136 .unwrap_or_default()
137 };
138 const LIB_KINDS: &[&str] = &["lib", "rlib", "dylib", "cdylib", "staticlib", "proc-macro"];
139 for target in targets {
140 let kinds = kind_of(target);
141 if kinds.iter().any(|kind| LIB_KINDS.contains(&kind.as_str())) {
142 return target
143 .get("name")
144 .and_then(|value| value.as_str())
145 .map(str::to_owned);
146 }
147 }
148 for target in targets {
149 if kind_of(target).iter().any(|kind| kind == "bin") {
150 return target
151 .get("name")
152 .and_then(|value| value.as_str())
153 .map(str::to_owned);
154 }
155 }
156 None
157}
158
159fn landing_html(entries: &[CrateEntry]) -> String {
162 let mut rows = String::new();
163 for entry in entries {
164 let description = if entry.description.is_empty() {
165 String::new()
166 } else {
167 format!(
168 " <span class=\"desc\">— {}</span>",
169 escape_html(&entry.description)
170 )
171 };
172 rows.push_str(&format!(
173 " <li><a href=\"{dir}/index.html\"><code>{name}</code></a>{description}</li>\n",
174 dir = escape_html(&entry.doc_dir),
175 name = escape_html(&entry.name),
176 ));
177 }
178 format!(
179 r#"<!DOCTYPE html>
180<html lang="en">
181 <head>
182 <meta charset="utf-8">
183 <meta name="viewport" content="width=device-width, initial-scale=1">
184 <title>Soma API documentation</title>
185 <style>
186 :root {{ color-scheme: light dark; }}
187 body {{
188 font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
189 max-width: 56rem; margin: 2rem auto; padding: 0 1.25rem;
190 line-height: 1.55;
191 }}
192 h1 {{ margin-bottom: 0.25rem; }}
193 .subtitle {{ color: color-mix(in srgb, currentColor 65%, transparent); }}
194 a {{ color: #0969da; text-decoration: none; }}
195 a:hover {{ text-decoration: underline; }}
196 @media (prefers-color-scheme: dark) {{ a {{ color: #58a6ff; }} }}
197 .card {{
198 display: block; border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
199 border-radius: 8px; padding: 0.9rem 1.1rem; margin: 1.25rem 0;
200 }}
201 .card strong {{ display: block; }}
202 ul.crates {{ list-style: none; padding: 0; }}
203 ul.crates li {{ padding: 0.3rem 0; border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent); }}
204 .desc {{ color: color-mix(in srgb, currentColor 65%, transparent); }}
205 footer {{ margin-top: 2rem; font-size: 0.85rem; color: color-mix(in srgb, currentColor 55%, transparent); }}
206 </style>
207 </head>
208 <body>
209 <h1>Soma API documentation</h1>
210 <p class="subtitle">Rust API reference for every crate in the soma workspace, built by <code>cargo xtask doc</code>.</p>
211 <a class="card" href="openapi.html">
212 <strong>REST API reference (OpenAPI)</strong>
213 <span class="desc">The <code>/v1/*</code> HTTP contract from <code>docs/generated/openapi.json</code>, rendered with Redoc.</span>
214 </a>
215 <h2>Workspace crates</h2>
216 <ul class="crates">
217{rows} </ul>
218 <footer>Generated by <code>cargo xtask doc</code>; deployed from <code>.github/workflows/docs.yml</code>.</footer>
219 </body>
220</html>
221"#
222 )
223}
224
225fn escape_html(text: &str) -> String {
229 text.replace('&', "&")
230 .replace('<', "<")
231 .replace('>', ">")
232 .replace('"', """)
233}
234
235const REDOC_HTML: &str = r#"<!DOCTYPE html>
239<html lang="en">
240 <head>
241 <meta charset="utf-8">
242 <meta name="viewport" content="width=device-width, initial-scale=1">
243 <title>Soma REST API — OpenAPI reference</title>
244 <style>
245 body { margin: 0; padding: 0; }
246 .back { font-family: system-ui, sans-serif; font-size: 0.9rem; padding: 0.5rem 1rem; }
247 </style>
248 </head>
249 <body>
250 <div class="back"><a href="index.html">← All Soma API docs</a></div>
251 <redoc spec-url="openapi.json"></redoc>
252 <script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
253 </body>
254</html>
255"#;