chore: add xtask
This commit is contained in:
parent
f1010e9d07
commit
4e45498393
6 changed files with 873 additions and 0 deletions
21
xtask/Cargo.toml
Normal file
21
xtask/Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
[package]
|
||||||
|
name = "xtask"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
# Standalone workspace so xtask compiles even when the parent rustelo workspace
|
||||||
|
# is mid-edit (the tool's whole purpose is fixing that state).
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
# xtask intentionally uses inline dep versions (not workspace = true) so it can
|
||||||
|
# build even when [workspace.dependencies] is mid-edit. It is the tool that
|
||||||
|
# manages that section, so it must not depend on it being well-formed.
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.6", features = ["derive"] }
|
||||||
|
toml = "1.1"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
pathdiff = "0.2"
|
||||||
|
indexmap = { version = "2.7", features = ["serde"] }
|
||||||
71
xtask/src/main.rs
Normal file
71
xtask/src/main.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
mod overlay;
|
||||||
|
mod registry;
|
||||||
|
mod render;
|
||||||
|
mod sync;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "xtask",
|
||||||
|
about = "Rustelo workspace meta-tools (deps sync, integrity checks)"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Cmd,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Sync [workspace.dependencies] from registry/Cargo.toml into a Cargo.toml.
|
||||||
|
SyncDeps(SyncDepsArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
pub struct SyncDepsArgs {
|
||||||
|
/// Verify only — exit non-zero if any target Cargo.toml differs from the rendered registry.
|
||||||
|
#[arg(long)]
|
||||||
|
pub check: bool,
|
||||||
|
|
||||||
|
/// Target Cargo.toml directory. Defaults to the rustelo workspace root.
|
||||||
|
/// For implementations, point this at the impl workspace dir (e.g. ../jpl-website).
|
||||||
|
/// Mutually exclusive with --all.
|
||||||
|
#[arg(long)]
|
||||||
|
pub target: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Path to the rustelo workspace root (where registry/Cargo.toml lives).
|
||||||
|
/// Used to compute relative path rebasing for impl targets.
|
||||||
|
#[arg(long, default_value = ".")]
|
||||||
|
pub rustelo_root: PathBuf,
|
||||||
|
|
||||||
|
/// Explicit path to an overlay file. Default: `{target}/rustelo-deps-overlay.toml`
|
||||||
|
/// (auto-detected; absent = no overlay). Cannot be combined with --all
|
||||||
|
/// (each impl uses its own auto-detected overlay).
|
||||||
|
#[arg(long)]
|
||||||
|
pub overlay: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Sync the rustelo workspace plus every implementation listed in
|
||||||
|
/// `registry/sync.toml` `[impls.known]`. Drift in any target sets a
|
||||||
|
/// non-zero exit status; all targets are visited regardless of intermediate
|
||||||
|
/// drift (no fail-fast). Mutually exclusive with --target and --overlay.
|
||||||
|
#[arg(long)]
|
||||||
|
pub all: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let result: Result<ExitCode> = match cli.cmd {
|
||||||
|
Cmd::SyncDeps(args) => sync::run(args),
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(code) => code,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("xtask: {e:#}");
|
||||||
|
ExitCode::from(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
229
xtask/src/overlay.rs
Normal file
229
xtask/src/overlay.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::registry::{Dep, Registry};
|
||||||
|
|
||||||
|
pub const OVERLAY_FILENAME: &str = "rustelo-deps-overlay.toml";
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Default)]
|
||||||
|
pub struct Overlay {
|
||||||
|
/// Parent layer this overlay extends, as a path to the directory containing
|
||||||
|
/// the parent `rustelo-deps-overlay.toml`, relative to THIS overlay's own
|
||||||
|
/// directory. Resolution walks the chain parent→child; feature additions and
|
||||||
|
/// extras accumulate down it under the same additive-only rule a single
|
||||||
|
/// overlay obeys over the registry. Absent = a one-link chain (the historical
|
||||||
|
/// single-overlay behavior, byte-for-byte).
|
||||||
|
#[serde(default)]
|
||||||
|
pub extends: Option<String>,
|
||||||
|
/// Feature additions to registry deps (or to extras introduced upstream in
|
||||||
|
/// the chain). Cannot override version or path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub overlay: IndexMap<String, FeatureOverlay>,
|
||||||
|
/// Deps the layer needs but that are not in the registry (impl-specific
|
||||||
|
/// feature crates, sibling-project paths, the layer's own crates). Paths are
|
||||||
|
/// rustelo-root-relative, like registry path-deps, and rebased per target.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra: IndexMap<String, Dep>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Default)]
|
||||||
|
pub struct FeatureOverlay {
|
||||||
|
#[serde(default)]
|
||||||
|
pub features: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One resolved overlay in a chain, paired with the canonical directory its file
|
||||||
|
/// lives in. The directory is needed only to resolve the next `extends` hop;
|
||||||
|
/// rendering rebases extra paths via the rustelo root, not this directory.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Layer {
|
||||||
|
pub overlay: Overlay,
|
||||||
|
pub dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads `{target_dir}/rustelo-deps-overlay.toml` if it exists. Absent file is
|
||||||
|
/// not an error — most layers do not need an overlay.
|
||||||
|
pub fn load(target_dir: &Path) -> Result<Option<Overlay>> {
|
||||||
|
let path = target_dir.join(OVERLAY_FILENAME);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(load_file(&path)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads an overlay from an explicit file path (used by the `--overlay` flag).
|
||||||
|
pub fn load_file(path: &Path) -> Result<Overlay> {
|
||||||
|
let text =
|
||||||
|
fs::read_to_string(path).with_context(|| format!("reading overlay {}", path.display()))?;
|
||||||
|
toml::from_str(&text).with_context(|| format!("parsing overlay {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the overlay chain for a target, ordered parent→child (the target's
|
||||||
|
/// own overlay is last, so its feature additions win the rendering order). Each
|
||||||
|
/// overlay's `extends` names the directory of its parent layer, relative to the
|
||||||
|
/// overlay's own location. A target with no overlay file yields an empty chain
|
||||||
|
/// (equivalent to "no overlay"); an `extends` that points at a directory with no
|
||||||
|
/// overlay file, or that forms a cycle, is an error.
|
||||||
|
pub fn load_chain(target_dir: &Path) -> Result<Vec<Layer>> {
|
||||||
|
let mut chain: Vec<Layer> = Vec::new();
|
||||||
|
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||||
|
let mut dir = target_dir.to_path_buf();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let canon = dir
|
||||||
|
.canonicalize()
|
||||||
|
.with_context(|| format!("canonicalize layer dir {}", dir.display()))?;
|
||||||
|
if !seen.insert(canon.clone()) {
|
||||||
|
bail!(
|
||||||
|
"overlay `extends` cycle detected: {} is reached twice",
|
||||||
|
canon.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let overlay = match load(&canon)? {
|
||||||
|
Some(o) => o,
|
||||||
|
None => {
|
||||||
|
if chain.is_empty() {
|
||||||
|
// Target itself has no overlay → no chain at all.
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
bail!(
|
||||||
|
"{} is referenced via `extends` but has no {}",
|
||||||
|
canon.display(),
|
||||||
|
OVERLAY_FILENAME
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let next = overlay
|
||||||
|
.extends
|
||||||
|
.as_ref()
|
||||||
|
.map(|rel| canon.join(rel));
|
||||||
|
chain.push(Layer {
|
||||||
|
overlay,
|
||||||
|
dir: canon,
|
||||||
|
});
|
||||||
|
match next {
|
||||||
|
Some(parent) => dir = parent,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.reverse();
|
||||||
|
Ok(chain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates an ordered overlay chain against the registry. The additive-only
|
||||||
|
/// rule recurses down the chain: a `[overlay.X]` may target a registry dep or an
|
||||||
|
/// extra introduced by an *upstream* layer; a `[extra.X]` may not collide with a
|
||||||
|
/// registry dep nor redefine an upstream extra (add features via `[overlay.X]`
|
||||||
|
/// instead).
|
||||||
|
pub fn validate_chain(layers: &[Layer], registry: &Registry) -> Result<()> {
|
||||||
|
let mut upstream_extras: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
for layer in layers {
|
||||||
|
let o = &layer.overlay;
|
||||||
|
for name in o.overlay.keys() {
|
||||||
|
if !registry.deps.contains_key(name) && !upstream_extras.contains(name) {
|
||||||
|
bail!(
|
||||||
|
"overlay references unknown dep `{name}` at {} — add it to registry/Cargo.toml, introduce it via an upstream [extra], or declare it in [extra]",
|
||||||
|
layer.dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (name, dep) in &o.extra {
|
||||||
|
if registry.deps.contains_key(name) {
|
||||||
|
bail!(
|
||||||
|
"[extra.{name}] at {} conflicts with a registry dep — use [overlay.{name}] for feature additions",
|
||||||
|
layer.dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if upstream_extras.contains(name) {
|
||||||
|
bail!(
|
||||||
|
"[extra.{name}] at {} redefines an extra from an upstream layer — use [overlay.{name}] to add features instead",
|
||||||
|
layer.dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dep.validate(name)?;
|
||||||
|
upstream_extras.insert(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges overlay feature additions onto a dep. Order preserved, duplicates
|
||||||
|
/// dropped.
|
||||||
|
pub fn apply_features(dep: &Dep, fo: Option<&FeatureOverlay>) -> Dep {
|
||||||
|
let mut merged = dep.clone();
|
||||||
|
if let Some(fo) = fo {
|
||||||
|
for f in &fo.features {
|
||||||
|
if !merged.features.contains(f) {
|
||||||
|
merged.features.push(f.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::registry::Registry;
|
||||||
|
|
||||||
|
fn registry_with(names: &[&str]) -> Registry {
|
||||||
|
let mut deps = IndexMap::new();
|
||||||
|
for n in names {
|
||||||
|
deps.insert(
|
||||||
|
(*n).to_string(),
|
||||||
|
Dep {
|
||||||
|
version: Some("1.0".into()),
|
||||||
|
rebase: true,
|
||||||
|
..Dep::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Registry { deps }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layer(toml_src: &str, dir: &str) -> Layer {
|
||||||
|
Layer {
|
||||||
|
overlay: toml::from_str(toml_src).expect("overlay parses"),
|
||||||
|
dir: PathBuf::from(dir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlay_may_feature_an_upstream_extra() {
|
||||||
|
let reg = registry_with(&["rustelo_server"]);
|
||||||
|
let l2 = layer("[extra.content-graph]\nversion = \"0.1\"\n", "/l2");
|
||||||
|
let l3 = layer("[overlay.content-graph]\nfeatures = [\"ssr\"]\n", "/l3");
|
||||||
|
validate_chain(&[l2, l3], ®).expect("upstream extra is a valid overlay target");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlay_on_unknown_dep_is_rejected() {
|
||||||
|
let reg = registry_with(&["rustelo_server"]);
|
||||||
|
let l3 = layer("[overlay.nope]\nfeatures = [\"x\"]\n", "/l3");
|
||||||
|
assert!(validate_chain(&[l3], ®).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redefining_an_upstream_extra_is_rejected() {
|
||||||
|
let reg = registry_with(&[]);
|
||||||
|
let l2 = layer("[extra.shared]\npath = \"../a/shared\"\n", "/l2");
|
||||||
|
let l3 = layer("[extra.shared]\npath = \"../b/shared\"\n", "/l3");
|
||||||
|
assert!(validate_chain(&[l2, l3], ®).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extra_colliding_with_registry_is_rejected() {
|
||||||
|
let reg = registry_with(&["rustelo_server"]);
|
||||||
|
let l3 = layer("[extra.rustelo_server]\nversion = \"9.9\"\n", "/l3");
|
||||||
|
assert!(validate_chain(&[l3], ®).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
201
xtask/src/registry.rs
Normal file
201
xtask/src/registry.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Registry {
|
||||||
|
pub deps: IndexMap<String, Dep>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Dep {
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub features: Vec<String>,
|
||||||
|
pub default_features: Option<bool>,
|
||||||
|
/// When true (default), `path` is rebased relative to the target's Cargo.toml
|
||||||
|
/// during sync. When false, `path` is rebased relative to the rustelo
|
||||||
|
/// workspace root and the same string ships to every target — meant for
|
||||||
|
/// paths that point outside the rustelo workspace.
|
||||||
|
pub rebase: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dep {
|
||||||
|
pub fn validate(&self, name: &str) -> Result<()> {
|
||||||
|
if self.version.is_none() && self.path.is_none() {
|
||||||
|
bail!("dep `{name}` declares neither `version` nor `path`");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_simple_inline(&self) -> bool {
|
||||||
|
self.path.is_none()
|
||||||
|
&& self.features.is_empty()
|
||||||
|
&& self.default_features.is_none()
|
||||||
|
&& self.version.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts both Cargo's compact form (`name = "x.y"`) and table form
|
||||||
|
/// (`name = { version = "x.y", features = [...] }`). The `rebase` field is
|
||||||
|
/// non-Cargo metadata used only by overlays' `[extra]` table; the registry
|
||||||
|
/// loader sources it from `registry/sync.toml` instead.
|
||||||
|
impl<'de> Deserialize<'de> for Dep {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum Value {
|
||||||
|
Version(String),
|
||||||
|
Table(Inner),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
struct Inner {
|
||||||
|
#[serde(default)]
|
||||||
|
version: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
features: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
default_features: Option<bool>,
|
||||||
|
#[serde(default = "default_rebase")]
|
||||||
|
rebase: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
match Value::deserialize(deserializer)? {
|
||||||
|
Value::Version(v) => Ok(Dep {
|
||||||
|
version: Some(v),
|
||||||
|
rebase: true,
|
||||||
|
..Dep::default()
|
||||||
|
}),
|
||||||
|
Value::Table(t) => Ok(Dep {
|
||||||
|
version: t.version,
|
||||||
|
path: t.path,
|
||||||
|
features: t.features,
|
||||||
|
default_features: t.default_features,
|
||||||
|
rebase: t.rebase,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_rebase() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CargoManifest {
|
||||||
|
workspace: WorkspaceSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WorkspaceSection {
|
||||||
|
#[serde(default)]
|
||||||
|
dependencies: IndexMap<String, Dep>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct SyncSidecar {
|
||||||
|
#[serde(default)]
|
||||||
|
no_rebase: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
impls: ImplsSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct ImplsSection {
|
||||||
|
#[serde(default)]
|
||||||
|
known: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loaded view of `registry/sync.toml` — non-Cargo metadata used by the xtask.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Sidecar {
|
||||||
|
/// Names of registry deps whose `path` is NOT rebased per target.
|
||||||
|
pub no_rebase: HashSet<String>,
|
||||||
|
/// Paths (relative to the rustelo workspace root) of implementations that
|
||||||
|
/// consume this registry. Used by `sync-deps --all` to fan out.
|
||||||
|
pub known_impls: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips the leading `../` from a path stored in `registry/Cargo.toml`,
|
||||||
|
/// yielding a path relative to the rustelo workspace root. The leading `../`
|
||||||
|
/// is mandatory: `registry/Cargo.toml` lives one level inside the workspace,
|
||||||
|
/// so Cargo expects every path entry to begin with `../`. This normalization
|
||||||
|
/// lets the renderer treat `Dep.path` as rustelo-root-relative just like the
|
||||||
|
/// previous custom-schema format did, keeping the path-rebase logic stable
|
||||||
|
/// across the schema change.
|
||||||
|
fn normalize_registry_path(name: &str, raw: &str) -> Result<String> {
|
||||||
|
let stripped = Path::new(raw).strip_prefix("..").map_err(|_| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"registry path `{raw}` for `{name}` must start with `../` (registry/Cargo.toml is one level inside the workspace)"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if stripped == Path::new("") {
|
||||||
|
bail!("registry path `{raw}` for `{name}` resolves to the workspace root itself");
|
||||||
|
}
|
||||||
|
Ok(PathBuf::from(stripped).to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads `registry/sync.toml` once. Returns an empty Sidecar if the file is
|
||||||
|
/// absent (no_rebase + no impls).
|
||||||
|
pub fn load_sidecar(rustelo_root: &Path) -> Result<Sidecar> {
|
||||||
|
let path = rustelo_root.join("registry/sync.toml");
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Sidecar::default());
|
||||||
|
}
|
||||||
|
let text = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("reading {}", path.display()))?;
|
||||||
|
let raw: SyncSidecar = toml::from_str(&text)
|
||||||
|
.with_context(|| format!("parsing {}", path.display()))?;
|
||||||
|
Ok(Sidecar {
|
||||||
|
no_rebase: raw.no_rebase.into_iter().collect(),
|
||||||
|
known_impls: raw.impls.known,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(rustelo_root: &Path) -> Result<Registry> {
|
||||||
|
let manifest_path = rustelo_root.join("registry/Cargo.toml");
|
||||||
|
let text = fs::read_to_string(&manifest_path)
|
||||||
|
.with_context(|| format!("reading {}", manifest_path.display()))?;
|
||||||
|
let manifest: CargoManifest = toml::from_str(&text)
|
||||||
|
.with_context(|| format!("parsing {}", manifest_path.display()))?;
|
||||||
|
|
||||||
|
let sidecar = load_sidecar(rustelo_root)?;
|
||||||
|
|
||||||
|
let mut deps: IndexMap<String, Dep> =
|
||||||
|
IndexMap::with_capacity(manifest.workspace.dependencies.len());
|
||||||
|
for (name, mut dep) in manifest.workspace.dependencies {
|
||||||
|
if sidecar.no_rebase.contains(&name) {
|
||||||
|
dep.rebase = false;
|
||||||
|
}
|
||||||
|
if let Some(p) = &dep.path {
|
||||||
|
dep.path = Some(normalize_registry_path(&name, p)?);
|
||||||
|
}
|
||||||
|
dep.validate(&name)?;
|
||||||
|
deps.insert(name, dep);
|
||||||
|
}
|
||||||
|
|
||||||
|
for n in &sidecar.no_rebase {
|
||||||
|
match deps.get(n) {
|
||||||
|
Some(d) if d.path.is_some() => {}
|
||||||
|
Some(_) => bail!(
|
||||||
|
"registry/sync.toml lists `{n}` in no_rebase but it has no `path` in registry/Cargo.toml"
|
||||||
|
),
|
||||||
|
None => bail!(
|
||||||
|
"registry/sync.toml lists `{n}` in no_rebase but it is not in registry/Cargo.toml"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Registry { deps })
|
||||||
|
}
|
||||||
129
xtask/src/render.rs
Normal file
129
xtask/src/render.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::overlay::{self, Layer};
|
||||||
|
use crate::registry::{Dep, Registry};
|
||||||
|
|
||||||
|
pub const REGION_START: &str =
|
||||||
|
"# >>> rustelo-sync:workspace-deps (managed by `cargo xtask sync-deps` — DO NOT EDIT)";
|
||||||
|
pub const REGION_END: &str = "# <<< rustelo-sync:workspace-deps";
|
||||||
|
|
||||||
|
/// Translates path-deps (stored as rustelo-root-relative strings on `Dep`)
|
||||||
|
/// into the form needed by a target Cargo.toml.
|
||||||
|
///
|
||||||
|
/// - `is_self` (target == rustelo root): emit the stored path as-is.
|
||||||
|
/// - `rebase = false`: emit the stored path as-is on every target. Used for
|
||||||
|
/// external paths whose location relative to siblings is identical from
|
||||||
|
/// each workspace (e.g. `../stratumiops/...`).
|
||||||
|
/// - Otherwise: resolve to absolute via the rustelo root, then diff against
|
||||||
|
/// the target's Cargo.toml location.
|
||||||
|
pub struct Rebase {
|
||||||
|
pub rustelo_root: PathBuf,
|
||||||
|
pub target_dir: PathBuf,
|
||||||
|
pub is_self: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rebase {
|
||||||
|
pub fn new(rustelo_root: &Path, target_dir: &Path) -> Result<Self> {
|
||||||
|
let rustelo_root = rustelo_root
|
||||||
|
.canonicalize()
|
||||||
|
.with_context(|| format!("canonicalize rustelo_root {}", rustelo_root.display()))?;
|
||||||
|
let target_dir = target_dir
|
||||||
|
.canonicalize()
|
||||||
|
.with_context(|| format!("canonicalize target {}", target_dir.display()))?;
|
||||||
|
let is_self = rustelo_root == target_dir;
|
||||||
|
Ok(Self {
|
||||||
|
rustelo_root,
|
||||||
|
target_dir,
|
||||||
|
is_self,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn translate_path(&self, path: &str, rebase: bool) -> String {
|
||||||
|
if self.is_self || !rebase {
|
||||||
|
return path.to_string();
|
||||||
|
}
|
||||||
|
let absolute = self.rustelo_root.join(path);
|
||||||
|
match pathdiff::diff_paths(&absolute, &self.target_dir) {
|
||||||
|
Some(rel) => rel.to_string_lossy().into_owned(),
|
||||||
|
None => path.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the entire managed region (markers included) for a target from an
|
||||||
|
/// ordered overlay chain (parent→child). Registry-dep features are folded across
|
||||||
|
/// every layer; extras accumulate down the chain and receive feature additions
|
||||||
|
/// from any layer that targets them. `validate_chain` has already guaranteed no
|
||||||
|
/// extra redefinition and no overlay of an unknown name, so accumulation here is
|
||||||
|
/// insert-once. A one-link chain (or empty chain) reproduces the historical
|
||||||
|
/// single-overlay (or no-overlay) rendering byte-for-byte.
|
||||||
|
pub fn render_region(registry: &Registry, layers: &[Layer], rebase: &Rebase) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str(REGION_START);
|
||||||
|
out.push('\n');
|
||||||
|
|
||||||
|
for (name, dep) in ®istry.deps {
|
||||||
|
let mut merged = dep.clone();
|
||||||
|
for layer in layers {
|
||||||
|
if let Some(fo) = layer.overlay.overlay.get(name) {
|
||||||
|
merged = overlay::apply_features(&merged, Some(fo));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(&render_entry(name, &merged, rebase));
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut extras: indexmap::IndexMap<String, Dep> = indexmap::IndexMap::new();
|
||||||
|
for layer in layers {
|
||||||
|
for (name, dep) in &layer.overlay.extra {
|
||||||
|
extras.insert(name.clone(), dep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Feature additions onto extras (a later layer featuring an upstream extra).
|
||||||
|
// Registry-targeting overlays are skipped here — they are not in `extras`.
|
||||||
|
for layer in layers {
|
||||||
|
for (name, fo) in &layer.overlay.overlay {
|
||||||
|
if let Some(dep) = extras.get_mut(name) {
|
||||||
|
*dep = overlay::apply_features(dep, Some(fo));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (name, dep) in &extras {
|
||||||
|
out.push_str(&render_entry(name, dep, rebase));
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str(REGION_END);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_entry(name: &str, dep: &Dep, rebase: &Rebase) -> String {
|
||||||
|
if dep.is_simple_inline() {
|
||||||
|
let v = dep.version.as_deref().unwrap_or_default();
|
||||||
|
return format!("{name} = \"{v}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
if let Some(v) = &dep.version {
|
||||||
|
parts.push(format!("version = \"{v}\""));
|
||||||
|
}
|
||||||
|
if let Some(p) = &dep.path {
|
||||||
|
let translated = rebase.translate_path(p, dep.rebase);
|
||||||
|
parts.push(format!("path = \"{translated}\""));
|
||||||
|
}
|
||||||
|
if let Some(df) = dep.default_features {
|
||||||
|
parts.push(format!("default-features = {df}"));
|
||||||
|
}
|
||||||
|
if !dep.features.is_empty() {
|
||||||
|
let feats = dep
|
||||||
|
.features
|
||||||
|
.iter()
|
||||||
|
.map(|f| format!("\"{f}\""))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
parts.push(format!("features = [{feats}]"));
|
||||||
|
}
|
||||||
|
format!("{name} = {{ {} }}", parts.join(", "))
|
||||||
|
}
|
||||||
222
xtask/src/sync.rs
Normal file
222
xtask/src/sync.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
|
||||||
|
use crate::SyncDepsArgs;
|
||||||
|
use crate::overlay::{self, Layer};
|
||||||
|
use crate::registry::{self, Registry};
|
||||||
|
use crate::render::{REGION_END, REGION_START, Rebase, render_region};
|
||||||
|
|
||||||
|
/// Outcome of a single-target sync.
|
||||||
|
enum SyncOutcome {
|
||||||
|
UpToDate,
|
||||||
|
Synced,
|
||||||
|
Drift,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(args: SyncDepsArgs) -> Result<ExitCode> {
|
||||||
|
if args.all && args.overlay.is_some() {
|
||||||
|
bail!(
|
||||||
|
"--overlay cannot be combined with --all (each impl uses its own auto-detected overlay)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if args.all && args.target.is_some() {
|
||||||
|
bail!("--target cannot be combined with --all");
|
||||||
|
}
|
||||||
|
|
||||||
|
let registry = registry::load(&args.rustelo_root)?;
|
||||||
|
|
||||||
|
let targets = resolve_targets(&args, ®istry)?;
|
||||||
|
|
||||||
|
let mut drifted = false;
|
||||||
|
for target in targets {
|
||||||
|
let outcome = sync_one(
|
||||||
|
&args.rustelo_root,
|
||||||
|
&target,
|
||||||
|
args.overlay.as_deref(),
|
||||||
|
®istry,
|
||||||
|
args.check,
|
||||||
|
)?;
|
||||||
|
let cargo_path = target.join("Cargo.toml");
|
||||||
|
match outcome {
|
||||||
|
SyncOutcome::UpToDate => {
|
||||||
|
println!("sync-deps: {} is up to date", cargo_path.display());
|
||||||
|
}
|
||||||
|
SyncOutcome::Synced => {
|
||||||
|
println!("sync-deps: wrote {}", cargo_path.display());
|
||||||
|
}
|
||||||
|
SyncOutcome::Drift => {
|
||||||
|
eprintln!(
|
||||||
|
"sync-deps: drift detected in {} — run `cargo xtask sync-deps` to fix",
|
||||||
|
cargo_path.display()
|
||||||
|
);
|
||||||
|
drifted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(if drifted {
|
||||||
|
ExitCode::from(1)
|
||||||
|
} else {
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the ordered list of target directories to sync.
|
||||||
|
///
|
||||||
|
/// - `--all`: rustelo workspace root + every path listed in
|
||||||
|
/// `registry/sync.toml` `[impls.known]` (resolved relative to the
|
||||||
|
/// rustelo root). Each impl path is validated to contain a `Cargo.toml`.
|
||||||
|
/// - Otherwise: just `--target` (defaults to ".").
|
||||||
|
fn resolve_targets(args: &SyncDepsArgs, _registry: &Registry) -> Result<Vec<PathBuf>> {
|
||||||
|
if args.all {
|
||||||
|
let sidecar = registry::load_sidecar(&args.rustelo_root)?;
|
||||||
|
let mut targets = Vec::with_capacity(1 + sidecar.known_impls.len());
|
||||||
|
targets.push(args.rustelo_root.clone());
|
||||||
|
for rel in &sidecar.known_impls {
|
||||||
|
let abs = args.rustelo_root.join(rel);
|
||||||
|
let cargo = abs.join("Cargo.toml");
|
||||||
|
if !cargo.exists() {
|
||||||
|
bail!(
|
||||||
|
"registry/sync.toml [impls.known] points at `{}` but {} does not exist",
|
||||||
|
rel,
|
||||||
|
cargo.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
targets.push(abs);
|
||||||
|
}
|
||||||
|
Ok(targets)
|
||||||
|
} else {
|
||||||
|
let target = args.target.clone().unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
Ok(vec![target])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syncs (or checks) one target. The overlay path can override auto-detection
|
||||||
|
/// of `{target}/rustelo-deps-overlay.toml`.
|
||||||
|
fn sync_one(
|
||||||
|
rustelo_root: &Path,
|
||||||
|
target_dir: &Path,
|
||||||
|
overlay_path: Option<&Path>,
|
||||||
|
registry: &Registry,
|
||||||
|
check: bool,
|
||||||
|
) -> Result<SyncOutcome> {
|
||||||
|
let rebase = Rebase::new(rustelo_root, target_dir)?;
|
||||||
|
|
||||||
|
let layers = load_chain(target_dir, overlay_path)?;
|
||||||
|
overlay::validate_chain(&layers, registry)?;
|
||||||
|
|
||||||
|
let cargo_path = target_dir.join("Cargo.toml");
|
||||||
|
let original = fs::read_to_string(&cargo_path)
|
||||||
|
.with_context(|| format!("reading {}", cargo_path.display()))?;
|
||||||
|
|
||||||
|
let rendered_region = render_region(registry, &layers, &rebase);
|
||||||
|
let new_contents = splice_region(&original, &rendered_region, &cargo_path)?;
|
||||||
|
|
||||||
|
if new_contents == original {
|
||||||
|
return Ok(SyncOutcome::UpToDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if check {
|
||||||
|
return Ok(SyncOutcome::Drift);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(&cargo_path, &new_contents)
|
||||||
|
.with_context(|| format!("writing {}", cargo_path.display()))?;
|
||||||
|
Ok(SyncOutcome::Synced)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the ordered overlay chain (parent→child) for a target. With an
|
||||||
|
/// explicit `--overlay` path the chain is that single file — the escape hatch
|
||||||
|
/// stays deliberately flat and does not follow `extends`. Otherwise the chain is
|
||||||
|
/// auto-resolved from `{target}/rustelo-deps-overlay.toml` and its `extends`
|
||||||
|
/// links.
|
||||||
|
fn load_chain(target_dir: &Path, explicit: Option<&Path>) -> Result<Vec<Layer>> {
|
||||||
|
match explicit {
|
||||||
|
Some(path) => {
|
||||||
|
let overlay = overlay::load_file(path)?;
|
||||||
|
let dir = path
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
Ok(vec![Layer { overlay, dir }])
|
||||||
|
}
|
||||||
|
None => overlay::load_chain(target_dir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the managed region inside [workspace.dependencies] with the rendered text.
|
||||||
|
///
|
||||||
|
/// If markers are absent, the region is inserted at the top of the
|
||||||
|
/// [workspace.dependencies] table (preserving any manually-curated lines below
|
||||||
|
/// the end marker — those will live outside the managed region).
|
||||||
|
fn splice_region(original: &str, region: &str, cargo_path: &Path) -> Result<String> {
|
||||||
|
if let (Some(start), Some(end)) = (original.find(REGION_START), original.find(REGION_END)) {
|
||||||
|
if end < start {
|
||||||
|
bail!(
|
||||||
|
"{}: end marker appears before start marker",
|
||||||
|
cargo_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let end_full = end + REGION_END.len();
|
||||||
|
let mut out = String::with_capacity(original.len());
|
||||||
|
out.push_str(&original[..start]);
|
||||||
|
out.push_str(region);
|
||||||
|
out.push_str(&original[end_full..]);
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(hdr_idx) = find_table_header(original, "[workspace.dependencies]") else {
|
||||||
|
bail!(
|
||||||
|
"{}: no [workspace.dependencies] section found and no existing sync markers",
|
||||||
|
cargo_path.display()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let body_start = original[hdr_idx..]
|
||||||
|
.find('\n')
|
||||||
|
.map(|n| hdr_idx + n + 1)
|
||||||
|
.unwrap_or(original.len());
|
||||||
|
|
||||||
|
let body_end = next_table_header(&original[body_start..])
|
||||||
|
.map(|off| body_start + off)
|
||||||
|
.unwrap_or(original.len());
|
||||||
|
|
||||||
|
let mut out = String::with_capacity(original.len() + region.len());
|
||||||
|
out.push_str(&original[..body_start]);
|
||||||
|
out.push_str(region);
|
||||||
|
out.push('\n');
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str(&original[body_end..]);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the offset of a specific table header (e.g. `[workspace.dependencies]`),
|
||||||
|
/// requiring it to start at column 0 of a line. Avoids false matches inside
|
||||||
|
/// comments or strings that mention the bracketed phrase.
|
||||||
|
fn find_table_header(text: &str, header: &str) -> Option<usize> {
|
||||||
|
let mut offset = 0;
|
||||||
|
for line in text.split_inclusive('\n') {
|
||||||
|
let body = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||||
|
if body == header {
|
||||||
|
return Some(offset);
|
||||||
|
}
|
||||||
|
offset += line.len();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the offset of the next top-level table header line (a line starting with `[`
|
||||||
|
/// at column 0) within `text`. Returns None if no such header exists.
|
||||||
|
fn next_table_header(text: &str) -> Option<usize> {
|
||||||
|
let mut offset = 0;
|
||||||
|
for line in text.split_inclusive('\n') {
|
||||||
|
let trimmed_start = line.trim_start_matches([' ', '\t']);
|
||||||
|
if trimmed_start.starts_with('[') && trimmed_start == line {
|
||||||
|
return Some(offset);
|
||||||
|
}
|
||||||
|
offset += line.len();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue