main.rsannotatedmain.rssource368 lines · 13.5 KB · raw
1//! Writes a Cargo workspace for the first-party crates from the buck
2//! graph, so `cargo pgrx install` works without buck while buck stays the
3//! source of truth.
4//!
5//! Every `first_party_*` target records its kind, crate root and deps as a
6//! `<name>-cargo` JSON file (`build/defs.bzl`). From those, the version,
7//! edition and supported majors in `build/defs.bzl`, and the version
8//! requirements in `third-party/Cargo.toml`, this tool writes the root
9//! `Cargo.toml`, one manifest per package, and the extension's
10//! `pgrx_embed` binary. Integration tests (a package with no library or
11//! binary, such as `tests/`) stay buck-only: they need buck's `$(location)`
12//! of the built extension.
13//!
14//! Usage:
15//!   cargo-gen generate <out-dir> <version> <edition> <majors> <third-party/Cargo.toml> <meta.json>...
16//!   cargo-gen write <generated-dir>    (copies into the checkout holding the cwd)
17
18use std::collections::BTreeMap;
19use std::error::Error;
20use std::fmt::Write as _;
21use std::path::{Path, PathBuf};
22
23use serde::Deserialize;
24
25type Res<T> = Result<T, Box<dyn Error>>;
26
27const LICENSE: &str = "MIT OR Apache-2.0";
28const AUTHORS: &str = "The postjevsql Authors";
29const THIRD_PARTY: &str = "//third-party:";
30
31const HEADER: &str = "# @generated by tools/cargo-gen from the BUCK files, build/defs.bzl and
32# third-party/Cargo.toml. Do not edit: change the BUCK file and run
33#   buck2 run //tools/cargo-gen:update
34# The drift test (//tools/cargo-gen:drift) fails until you do.
35";
36
37#[derive(Deserialize)]
38struct Meta {
39    package: String,
40    name: String,
41    kind: Kind,
42    crate_root: String,
43    deps: Vec<String>,
44    pgrx_extension: bool,
45}
46
47#[derive(Deserialize, PartialEq, Clone, Copy)]
48#[serde(rename_all = "lowercase")]
49enum Kind {
50    Library,
51    Binary,
52    Test,
53}
54
55/// One Cargo package: the library or binary target of a buck package,
56/// with the deps of the unit tests that share its crate root.
57struct Package<'a> {
58    main: &'a Meta,
59    /// Binaries beside a library, as `[[bin]]`s that link it.
60    bins: Vec<&'a Meta>,
61    dev_deps: Vec<&'a str>,
62}
63
64fn main() {
65    let args: Vec<String> = std::env::args().skip(1).collect();
66    let result = match args.first().map(String::as_str) {
67        Some("generate") if args.len() >= 6 => generate(&args[1..]),
68        Some("write") if args.len() == 2 => write(Path::new(&args[1])),
69        _ => Err("usage: cargo-gen generate <out> <version> <edition> <majors> <third-party/Cargo.toml> <meta.json>... | cargo-gen write <generated-dir>".into()),
70    };
71    if let Err(e) = result {
72        eprintln!("cargo-gen: {e}");
73        std::process::exit(1);
74    }
75}
76
77fn generate(args: &[String]) -> Res<()> {
78    let out = PathBuf::from(&args[0]);
79    let version = &args[1];
80    let edition = &args[2];
81    let majors: Vec<&str> = args[3].split(',').filter(|m| !m.is_empty()).collect();
82    let Some(default_major) = majors.last() else {
83        return Err("no supported PostgreSQL majors".into());
84    };
85    let third_party: toml::Table = toml::from_str(&std::fs::read_to_string(&args[4])?)?;
86    let third_party = third_party
87        .get("dependencies")
88        .and_then(toml::Value::as_table)
89        .ok_or("third-party/Cargo.toml has no [dependencies]")?;
90
91    let metas = args[5..]
92        .iter()
93        .map(|p| Ok(serde_json::from_str::<Meta>(&std::fs::read_to_string(p)?)?))
94        .collect::<Res<Vec<Meta>>>()?;
95    let packages = packages(&metas)?;
96
97    // `//pkg:name` of each package's main target -> its Cargo name.
98    let first_party: BTreeMap<String, &str> = packages
99        .values()
100        .map(|p| (format!("//{}:{}", p.main.package, p.main.name), p.main.name.as_str()))
101        .collect();
102    let crate_dep = |label: &str| -> Res<Dep> {
103        if let Some(name) = label.strip_prefix(THIRD_PARTY) {
104            if !third_party.contains_key(name) {
105                return Err(format!("{label} is not in third-party/Cargo.toml").into());
106            }
107            Ok(Dep::Third(name.to_owned()))
108        } else if let Some(name) = first_party.get(label) {
109            Ok(Dep::First((*name).to_owned()))
110        } else {
111            Err(format!("{label} is neither third-party nor a first-party library").into())
112        }
113    };
114
115    // A package needs a pgNN feature when it reaches pgrx at all: a
116    // fixpoint over the (acyclic) first-party graph.
117    let mut uses_pgrx: BTreeMap<&str, bool> =
118        packages.values().map(|p| (p.main.name.as_str(), false)).collect();
119    loop {
120        let mut changed = false;
121        for p in packages.values() {
122            let reaches = p.main.deps.iter().any(|d| {
123                d == "//third-party:pgrx" || first_party.get(d.as_str()).is_some_and(|n| uses_pgrx[n])
124            });
125            if reaches && !uses_pgrx[p.main.name.as_str()] {
126                uses_pgrx.insert(p.main.name.as_str(), true);
127                changed = true;
128            }
129        }
130        if !changed {
131            break;
132        }
133    }
134
135    let mut used_third_party = std::collections::BTreeSet::new();
136    for (dir, pkg) in &packages {
137        let mut s = String::from(HEADER);
138        let name = &pkg.main.name;
139        writeln!(s, "\n[package]\nname = {}", q(name))?;
140        for key in ["version", "edition", "license", "authors", "publish"] {
141            writeln!(s, "{key}.workspace = true")?;
142        }
143        let root = &pkg.main.crate_root;
144        match pkg.main.kind {
145            Kind::Library if pkg.main.pgrx_extension => {
146                writeln!(s, "\n[lib]\npath = {}\ncrate-type = [\"cdylib\", \"lib\"]", q(root))?;
147                writeln!(
148                    s,
149                    "\n[[bin]]\nname = {}\npath = \"src/bin/pgrx_embed.rs\"",
150                    q(&format!("pgrx_embed_{}", name.replace('-', "_")))
151                )?;
152                let embed = out.join(dir).join("src/bin/pgrx_embed.rs");
153                std::fs::create_dir_all(embed.parent().unwrap())?;
154                std::fs::write(
155                    &embed,
156                    "// @generated by tools/cargo-gen: the schema binary `cargo pgrx` runs.\n::pgrx::pgrx_embed!();\n",
157                )?;
158            }
159            Kind::Library => writeln!(s, "\n[lib]\npath = {}", q(root))?,
160            Kind::Binary => writeln!(s, "\n[[bin]]\nname = {}\npath = {}", q(name), q(root))?,
161            Kind::Test => unreachable!("packages() never picks a test"),
162        }
163        for bin in &pkg.bins {
164            // One binary takes the package's name, as src/main.rs would.
165            let bin_name = if pkg.bins.len() == 1 { name } else { &bin.name };
166            writeln!(s, "\n[[bin]]\nname = {}\npath = {}", q(bin_name), q(&bin.crate_root))?;
167        }
168
169        // A binary's deps join the package's, less the library it links.
170        let own = format!("//{}:{}", pkg.main.package, pkg.main.name);
171        let mut labels: Vec<&str> = pkg.main.deps.iter().map(String::as_str).collect();
172        for d in pkg.bins.iter().flat_map(|b| &b.deps) {
173            if *d != own && !labels.contains(&d.as_str()) {
174                labels.push(d);
175            }
176        }
177        let deps = labels.iter().map(|d| crate_dep(d)).collect::<Res<Vec<Dep>>>()?;
178        if uses_pgrx[name.as_str()] {
179            writeln!(s, "\n[features]")?;
180            if pkg.main.pgrx_extension {
181                writeln!(s, "default = [{}]\npg_test = []", q(&format!("pg{default_major}")))?;
182            }
183            for major in &majors {
184                let fwd: Vec<String> = deps
185                    .iter()
186                    .filter(|d| match d {
187                        Dep::Third(n) => n == "pgrx",
188                        Dep::First(n) => uses_pgrx[n.as_str()],
189                    })
190                    .map(|d| q(&format!("{}/pg{major}", d.name())))
191                    .collect();
192                writeln!(s, "pg{major} = [{}]", fwd.join(", "))?;
193            }
194        }
195        write_deps(&mut s, "dependencies", &deps, &mut used_third_party)?;
196        let dev = pkg
197            .dev_deps
198            .iter()
199            .filter(|d| !labels.contains(d))
200            .map(|d| crate_dep(d))
201            .collect::<Res<Vec<Dep>>>()?;
202        write_deps(&mut s, "dev-dependencies", &dev, &mut used_third_party)?;
203        write_file(&out.join(dir).join("Cargo.toml"), &s)?;
204    }
205
206    // The workspace root.
207    let mut s = String::from(HEADER);
208    writeln!(s, "\n[workspace]\nresolver = \"3\"\nmembers = [")?;
209    for dir in packages.keys() {
210        writeln!(s, "    {},", q(dir))?;
211    }
212    writeln!(s, "]\n# buck's crates.io resolution, not a member.\nexclude = [\"third-party\"]")?;
213    writeln!(
214        s,
215        "\n[workspace.package]\nversion = {}\nedition = {}\nlicense = {}\nauthors = [{}]\npublish = false",
216        q(version),
217        q(edition),
218        q(LICENSE),
219        q(AUTHORS)
220    )?;
221    writeln!(s, "\n[workspace.dependencies]")?;
222    for (dir, pkg) in &packages {
223        writeln!(s, "{} = {{ path = {}, version = {} }}", pkg.main.name, q(dir), q(&format!("={version}")))?;
224    }
225    for name in &used_third_party {
226        let mut value = third_party[name.as_str()].clone();
227        if name == "pgrx" {
228            pin_pgrx(&mut value)?;
229        }
230        // A path in third-party/Cargo.toml (the jevcrates submodule) is
231        // relative to third-party/; the root manifest is one level up.
232        if let Some(path) = value.get_mut("path")
233            && let toml::Value::String(p) = path
234        {
235            *p = format!("third-party/{p}");
236        }
237        writeln!(s, "{name} = {value}")?;
238    }
239    // pgrx's template: a Postgres ERROR unwinds through Rust frames.
240    writeln!(
241        s,
242        "\n[profile.dev]\npanic = \"unwind\"\n\n[profile.release]\npanic = \"unwind\"\nopt-level = 3\nlto = \"fat\"\ncodegen-units = 1"
243    )?;
244    write_file(&out.join("Cargo.toml"), &s)
245}
246
247/// The buck packages with a library or binary, keyed by directory.
248fn packages(metas: &[Meta]) -> Res<BTreeMap<String, Package<'_>>> {
249    let mut out: BTreeMap<String, Package> = BTreeMap::new();
250    // A library is the package's main target, and binaries beside it
251    // become its `[[bin]]`s; otherwise a package has one target.
252    let mut metas_sorted: Vec<&Meta> = metas.iter().filter(|m| m.kind != Kind::Test).collect();
253    metas_sorted.sort_by_key(|m| m.kind != Kind::Library);
254    for m in metas_sorted {
255        match out.get_mut(&m.package) {
256            None => {
257                out.insert(m.package.clone(), Package { main: m, bins: vec![], dev_deps: vec![] });
258            }
259            Some(p) if p.main.kind == Kind::Library && m.kind == Kind::Binary && !p.main.pgrx_extension => {
260                p.bins.push(m);
261            }
262            Some(p) => {
263                return Err(format!(
264                    "{} has two Cargo targets, {} and {}; a Cargo package has one library and binaries",
265                    m.package, p.main.name, m.name
266                )
267                .into());
268            }
269        }
270    }
271    for t in metas.iter().filter(|m| m.kind == Kind::Test) {
272        if let Some(p) = out.get_mut(&t.package)
273            && p.main.crate_root == t.crate_root
274        {
275            p.dev_deps.extend(t.deps.iter().map(String::as_str));
276        }
277    }
278    Ok(out)
279}
280
281enum Dep {
282    First(String),
283    Third(String),
284}
285
286impl Dep {
287    fn name(&self) -> &str {
288        match self {
289            Dep::First(n) | Dep::Third(n) => n,
290        }
291    }
292}
293
294fn write_deps(
295    s: &mut String,
296    section: &str,
297    deps: &[Dep],
298    used: &mut std::collections::BTreeSet<String>,
299) -> Res<()> {
300    if deps.is_empty() {
301        return Ok(());
302    }
303    let mut names: Vec<&str> = deps.iter().map(Dep::name).collect();
304    names.sort_unstable();
305    names.dedup();
306    writeln!(s, "\n[{section}]")?;
307    for n in names {
308        writeln!(s, "{n}.workspace = true")?;
309    }
310    for d in deps {
311        if let Dep::Third(n) = d {
312            used.insert(n.clone());
313        }
314    }
315    Ok(())
316}
317
318/// pgrx must be an exact pin (CLAUDE.md §3), and its major feature is the
319/// member's to choose, so the workspace entry drops third-party's.
320fn pin_pgrx(value: &mut toml::Value) -> Res<()> {
321    let t = value.as_table_mut().ok_or("third-party pgrx must be a table")?;
322    let pinned = t.get("version").and_then(toml::Value::as_str).is_some_and(|v| v.starts_with('='));
323    if !pinned {
324        return Err("third-party pgrx must be pinned with `=`".into());
325    }
326    if let Some(toml::Value::Array(features)) = t.get_mut("features") {
327        features.retain(|f| !f.as_str().is_some_and(|f| f.starts_with("pg") && f[2..].bytes().all(|b| b.is_ascii_digit())));
328        if features.is_empty() {
329            t.remove("features");
330        }
331    }
332    Ok(())
333}
334
335fn q(s: &str) -> String {
336    toml::Value::String(s.to_owned()).to_string()
337}
338
339fn write_file(path: &Path, contents: &str) -> Res<()> {
340    std::fs::create_dir_all(path.parent().ok_or("no parent")?)?;
341    Ok(std::fs::write(path, contents)?)
342}
343
344/// Copies a generated tree into the checkout holding the cwd, found by its
345/// `.buckroot`.
346fn write(generated: &Path) -> Res<()> {
347    let cwd = std::env::current_dir()?;
348    let root = cwd
349        .ancestors()
350        .find(|d| d.join(".buckroot").exists())
351        .ok_or("not inside a buck checkout (no .buckroot above the cwd)")?;
352    copy_tree(generated, generated, root)
353}
354
355fn copy_tree(base: &Path, dir: &Path, root: &Path) -> Res<()> {
356    for entry in std::fs::read_dir(dir)? {
357        let path = entry?.path();
358        if path.is_dir() {
359            copy_tree(base, &path, root)?;
360        } else {
361            let dest = root.join(path.strip_prefix(base)?);
362            std::fs::create_dir_all(dest.parent().ok_or("no parent")?)?;
363            std::fs::copy(&path, &dest)?;
364            println!("wrote {}", dest.display());
365        }
366    }
367    Ok(())
368}