use std::collections::BTreeMap;
use std::env;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
fn main() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
emit_skill_hosts(&manifest_dir);
emit_auth_matrix(&manifest_dir);
emit_build_info(&manifest_dir);
}
fn emit_skill_hosts(manifest_dir: &Path) {
let skill_json_path = manifest_dir.join("src/skill_install/skill.json");
println!("cargo:rerun-if-changed=src/skill_install/skill.json");
let content = fs::read_to_string(&skill_json_path)
.unwrap_or_else(|e| panic!("read {}: {e}", skill_json_path.display()));
let manifest: serde_json::Value = serde_json::from_str(&content)
.unwrap_or_else(|e| panic!("parse {}: {e}", skill_json_path.display()));
let install = manifest
.get("install")
.and_then(|v| v.as_object())
.unwrap_or_else(|| {
panic!(
"{}: \"install\" must be an object (host -> command map)",
skill_json_path.display()
)
});
if install.is_empty() {
panic!(
"{}: install map is empty — at least one host required",
skill_json_path.display()
);
}
let mut hosts: Vec<(String, String, String, String)> = Vec::with_capacity(install.len());
for (key, cmd_value) in install {
let cmd = cmd_value.as_str().unwrap_or_else(|| {
panic!(
"{}: install.{key:?} must be a string",
skill_json_path.display()
)
});
let tokens: Vec<&str> = cmd.split_whitespace().collect();
if tokens.len() != 6
|| tokens[0] != "git"
|| tokens[1] != "clone"
|| tokens[2] != "--depth"
|| tokens[3] != "1"
{
panic!(
"{}: install.{key:?} must match `git clone --depth 1 <url> <dest>` (got {} tokens: {cmd:?})",
skill_json_path.display(),
tokens.len(),
);
}
let url = tokens[4].to_string();
let dest = tokens[5].to_string();
if dest.ends_with(".git") {
panic!(
"{}: install.{key:?} dest {dest:?} ends in `.git` — host commands must terminate with an explicit destination, not the bare repo name",
skill_json_path.display()
);
}
let variant = pascal_case(key).unwrap_or_else(|e| {
panic!(
"{}: install.{key:?} is not a valid Rust identifier: {e}",
skill_json_path.display()
)
});
hosts.push((key.clone(), variant, url, dest));
}
hosts.sort_by(|a, b| a.0.cmp(&b.0));
let mut src = String::new();
src.push_str(
"// @generated by build.rs from src/skill_install/skill.json. Do not edit by hand.\n",
);
src.push_str(
"// Add or remove hosts via the JSON file and `cargo build` regenerates this file.\n\n",
);
src.push_str("/// Hosts the binary knows how to install into. Surface names match\n");
src.push_str("/// `src/skill_install/skill.json` keys verbatim via\n");
src.push_str("/// `rename_all = \"snake_case\"`.\n");
src.push_str("#[derive(Clone, Copy, Debug, PartialEq, Eq, ::clap::ValueEnum)]\n");
src.push_str("#[value(rename_all = \"snake_case\")]\n");
src.push_str("pub enum SkillHost {\n");
for (_, variant, _, _) in &hosts {
src.push_str(&format!(" {variant},\n"));
}
src.push_str("}\n\n");
src.push_str("/// Host names accepted by `xr skill install <host>`, in JSON-key sort order.\n");
src.push_str("/// Stays in lockstep with [`SkillHost`] because both are generated from the\n");
src.push_str("/// same source.\n");
src.push_str("pub const KNOWN_HOSTS: &[&str] = &[\n");
for (key, _, _, _) in &hosts {
src.push_str(&format!(" {key:?},\n"));
}
src.push_str("];\n\n");
src.push_str("/// Resolve a host enum to its `(url, dest_template)` pair, parsed at build\n");
src.push_str("/// time from the install command in `src/skill_install/skill.json`. Pure\n");
src.push_str("/// function — no I/O, no side effects.\n");
src.push_str("pub fn resolve_host(host: SkillHost) -> (&'static str, &'static str) {\n");
src.push_str(" match host {\n");
for (_, variant, url, dest) in &hosts {
src.push_str(&format!(
" SkillHost::{variant} => ({url:?}, {dest:?}),\n"
));
}
src.push_str(" }\n");
src.push_str("}\n\n");
src.push_str("/// JSON-key string for the envelope's `host` field. Generated alongside the\n");
src.push_str("/// enum so the surface stays in lockstep with the JSON contract.\n");
src.push_str("pub fn host_envelope_str(host: SkillHost) -> &'static str {\n");
src.push_str(" match host {\n");
for (key, variant, _, _) in &hosts {
src.push_str(&format!(" SkillHost::{variant} => {key:?},\n"));
}
src.push_str(" }\n");
src.push_str("}\n");
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
let out_path = out_dir.join("generated_hosts.rs");
fs::write(&out_path, src)
.unwrap_or_else(|e| panic!("cannot write {}: {e}", out_path.display()));
}
fn pascal_case(snake: &str) -> Result<String, String> {
if snake.is_empty() {
return Err("empty identifier".into());
}
if snake.starts_with(|c: char| c.is_ascii_digit()) {
return Err(format!("{snake:?} starts with a digit"));
}
if !snake
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(format!(
"{snake:?} contains non-snake_case ASCII characters"
));
}
let mut out = String::with_capacity(snake.len());
for word in snake.split('_') {
let mut chars = word.chars();
if let Some(first) = chars.next() {
out.push(first.to_ascii_uppercase());
out.push_str(chars.as_str());
}
}
Ok(out)
}
const SHORTCUT_TEMPLATES: &[(&str, &str)] = &[
("POST", "/2/tweets"),
("GET", "/2/tweets/{id}"),
("DELETE", "/2/tweets/{id}"),
("GET", "/2/tweets/search/recent"),
("GET", "/2/users/me"),
("GET", "/2/users/by/username/{username}"),
("GET", "/2/users/{id}/timelines/reverse_chronological"),
("GET", "/2/users/{id}/mentions"),
("GET", "/2/users/{id}/followers"),
("GET", "/2/users/{id}/liked_tweets"),
("GET", "/2/users/{id}/blocking"),
("POST", "/2/users/{id}/likes"),
("DELETE", "/2/users/{id}/likes/{tweet_id}"),
("POST", "/2/users/{id}/retweets"),
("DELETE", "/2/users/{id}/retweets/{source_tweet_id}"),
("GET", "/2/users/{id}/bookmarks"),
("POST", "/2/users/{id}/bookmarks"),
("DELETE", "/2/users/{id}/bookmarks/{tweet_id}"),
("GET", "/2/users/{id}/following"),
("POST", "/2/users/{id}/following"),
(
"DELETE",
"/2/users/{source_user_id}/following/{target_user_id}",
),
("GET", "/2/users/{id}/muting"),
("POST", "/2/users/{id}/muting"),
(
"DELETE",
"/2/users/{source_user_id}/muting/{target_user_id}",
),
("POST", "/2/dm_conversations/with/{participant_id}/messages"),
("GET", "/2/dm_events"),
("GET", "/2/usage/tweets"),
("POST", "/2/media/upload"),
("GET", "/2/media/upload"),
("POST", "/2/media/upload/initialize"),
("POST", "/2/media/upload/{id}/append"),
("POST", "/2/media/upload/{id}/finalize"),
];
#[derive(Deserialize)]
struct Spec {
#[serde(default)]
paths: BTreeMap<String, PathItem>,
}
#[derive(Deserialize, Default)]
struct PathItem {
#[serde(default)]
get: Option<Operation>,
#[serde(default)]
post: Option<Operation>,
#[serde(default)]
put: Option<Operation>,
#[serde(default)]
delete: Option<Operation>,
#[serde(default)]
patch: Option<Operation>,
}
#[derive(Deserialize)]
struct Operation {
#[serde(default)]
security: Option<Vec<BTreeMap<String, Vec<String>>>>,
}
struct Entry {
method: &'static str,
path: &'static str,
schemes: Vec<SchemeRepr>,
}
enum SchemeRepr {
Bearer,
OAuth1User,
OAuth2User(Vec<String>),
}
fn emit_auth_matrix(manifest_dir: &Path) {
let spec_path = manifest_dir.join("vendor").join("x-api-openapi.json");
println!("cargo::rerun-if-changed=vendor/x-api-openapi.json");
let content = fs::read_to_string(&spec_path)
.unwrap_or_else(|e| panic!("read {}: {e}", spec_path.display()));
let spec: Spec = serde_json::from_str(&content)
.unwrap_or_else(|e| panic!("parse {}: {e}", spec_path.display()));
let mut entries: Vec<Entry> = Vec::with_capacity(SHORTCUT_TEMPLATES.len());
for (method, path) in SHORTCUT_TEMPLATES {
let item = spec.paths.get(*path).unwrap_or_else(|| {
panic!(
"{}: SHORTCUT_TEMPLATES references {path:?} but spec has no such path; \
fix the allowlist or refresh the spec",
spec_path.display()
)
});
let op = match *method {
"GET" => item.get.as_ref(),
"POST" => item.post.as_ref(),
"PUT" => item.put.as_ref(),
"DELETE" => item.delete.as_ref(),
"PATCH" => item.patch.as_ref(),
other => panic!("SHORTCUT_TEMPLATES has unsupported method {other:?} for {path:?}"),
};
let Some(op) = op else {
panic!(
"{}: spec path {path:?} declares no `{method}` operation; \
the shortcut layer claims to call it — fix the allowlist or refresh the spec",
spec_path.display()
);
};
let Some(security) = op.security.as_ref() else {
continue;
};
if security.is_empty() {
continue;
}
let mut schemes = Vec::with_capacity(security.len());
for entry in security {
if entry.len() != 1 {
panic!(
"{}: {method} {path}: each security entry must name exactly one scheme \
(got {} keys: {:?})",
spec_path.display(),
entry.len(),
entry.keys().collect::<Vec<_>>(),
);
}
let (key, scopes) = entry.iter().next().expect("len == 1 above");
let scheme = match key.as_str() {
"BearerToken" => SchemeRepr::Bearer,
"OAuth2UserToken" => SchemeRepr::OAuth2User(scopes.clone()),
"UserToken" => SchemeRepr::OAuth1User,
other => panic!(
"{}: {method} {path}: unknown security scheme {other:?} — \
extend the build.rs translation table or update the spec",
spec_path.display()
),
};
schemes.push(scheme);
}
entries.push(Entry {
method,
path,
schemes,
});
}
let mut src = String::new();
src.push_str(
"// @generated by build.rs from vendor/x-api-openapi.json. Do not edit by hand.\n",
);
src.push_str("// Refresh via scripts/refresh-vendor-spec.sh; `cargo build` regenerates.\n\n");
for (i, entry) in entries.iter().enumerate() {
writeln!(
&mut src,
"static SUP_{i}: &[AuthScheme] = &[{}];",
render_schemes(&entry.schemes)
)
.expect("write to String never fails");
}
src.push('\n');
let mut map = phf_codegen::Map::new();
let values: Vec<String> = (0..entries.len()).map(|i| format!("&SUP_{i}")).collect();
for (entry, value) in entries.iter().zip(values.iter()) {
let key = format!("{}\0{}", entry.method, entry.path);
map.entry(key, value.as_str());
}
writeln!(
&mut src,
"pub static AUTH_MATRIX: phf::Map<&'static str, &'static [AuthScheme]> = {};",
map.build()
)
.expect("write to String never fails");
src.push('\n');
src.push_str("pub const SHORTCUT_TEMPLATES: &[(&str, &str)] = &[\n");
for entry in SHORTCUT_TEMPLATES {
writeln!(&mut src, " ({:?}, {:?}),", entry.0, entry.1)
.expect("write to String never fails");
}
src.push_str("];\n");
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
let out_path = out_dir.join("auth_matrix.rs");
fs::write(&out_path, src)
.unwrap_or_else(|e| panic!("cannot write {}: {e}", out_path.display()));
}
fn emit_build_info(manifest_dir: &Path) {
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=vendor/spec-metadata.json");
if let Some(sha) = run_git(manifest_dir, &["rev-parse", "HEAD"]) {
println!("cargo:rustc-env=XURL_CRATE_GIT_SHA={sha}");
}
let metadata_path = manifest_dir.join("vendor").join("spec-metadata.json");
let metadata_content = fs::read_to_string(&metadata_path).unwrap_or_else(|e| {
panic!(
"read {}: {e} — run scripts/refresh-x-openapi.sh to regenerate",
metadata_path.display()
)
});
let metadata: serde_json::Value = serde_json::from_str(&metadata_content).unwrap_or_else(|e| {
panic!("parse {}: {e}", metadata_path.display());
});
let metadata_version = string_field(&metadata, "info_version", &metadata_path);
let metadata_sha256 = string_field(&metadata, "content_sha256", &metadata_path);
let metadata_refreshed = string_field(&metadata, "refreshed_at", &metadata_path);
let spec_path = manifest_dir.join("vendor").join("x-api-openapi.json");
let spec_content = fs::read_to_string(&spec_path)
.unwrap_or_else(|e| panic!("read {}: {e}", spec_path.display()));
let spec: serde_json::Value = serde_json::from_str(&spec_content)
.unwrap_or_else(|e| panic!("parse {}: {e}", spec_path.display()));
let spec_info_version = spec
.get("info")
.and_then(|v| v.get("version"))
.and_then(|v| v.as_str())
.unwrap_or_else(|| {
panic!(
"{}: must have an `info.version` string field",
spec_path.display()
)
});
if spec_info_version != metadata_version {
panic!(
"vendor/x-api-openapi.json `info.version` is {spec_info_version:?} but \
vendor/spec-metadata.json `info_version` is {metadata_version:?} — \
run scripts/refresh-x-openapi.sh to resync"
);
}
println!("cargo:rustc-env=XURL_API_SPEC_VERSION={metadata_version}");
println!("cargo:rustc-env=XURL_API_SPEC_SHA256={metadata_sha256}");
println!("cargo:rustc-env=XURL_API_SPEC_DATE={metadata_refreshed}");
}
fn string_field<'a>(value: &'a serde_json::Value, field: &str, path: &Path) -> &'a str {
value
.get(field)
.and_then(|v| v.as_str())
.unwrap_or_else(|| {
panic!(
"{}: missing required string field {field:?} — \
run scripts/refresh-x-openapi.sh to regenerate",
path.display()
)
})
}
fn run_git(cwd: &Path, args: &[&str]) -> Option<String> {
let output = std::process::Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let trimmed = stdout.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn render_schemes(schemes: &[SchemeRepr]) -> String {
let mut out = String::new();
for (i, s) in schemes.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
match s {
SchemeRepr::Bearer => out.push_str("AuthScheme::Bearer"),
SchemeRepr::OAuth1User => out.push_str("AuthScheme::OAuth1User"),
SchemeRepr::OAuth2User(scopes) => {
out.push_str("AuthScheme::OAuth2User(&[");
for (j, scope) in scopes.iter().enumerate() {
if j > 0 {
out.push_str(", ");
}
write!(&mut out, "{scope:?}").expect("write to String never fails");
}
out.push_str("])");
}
}
}
out
}