chore: add xtask

This commit is contained in:
Jesús Pérez 2026-07-18 20:16:44 +01:00
parent f1010e9d07
commit 4e45498393
Signed by: jesus
GPG key ID: 9F243E355E0BC939
6 changed files with 873 additions and 0 deletions

21
xtask/Cargo.toml Normal file
View 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
View 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
View 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], &reg).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], &reg).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], &reg).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], &reg).is_err());
}
}

201
xtask/src/registry.rs Normal file
View 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
View 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 &registry.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
View 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, &registry)?;
let mut drifted = false;
for target in targets {
let outcome = sync_one(
&args.rustelo_root,
&target,
args.overlay.as_deref(),
&registry,
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
}