diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..209bbaf --- /dev/null +++ b/xtask/Cargo.toml @@ -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"] } diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..7d8c543 --- /dev/null +++ b/xtask/src/main.rs @@ -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, + + /// 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, + + /// 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 = match cli.cmd { + Cmd::SyncDeps(args) => sync::run(args), + }; + match result { + Ok(code) => code, + Err(e) => { + eprintln!("xtask: {e:#}"); + ExitCode::from(2) + } + } +} diff --git a/xtask/src/overlay.rs b/xtask/src/overlay.rs new file mode 100644 index 0000000..df8a5d4 --- /dev/null +++ b/xtask/src/overlay.rs @@ -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, + /// Feature additions to registry deps (or to extras introduced upstream in + /// the chain). Cannot override version or path. + #[serde(default)] + pub overlay: IndexMap, + /// 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, +} + +#[derive(Deserialize, Debug, Default)] +pub struct FeatureOverlay { + #[serde(default)] + pub features: Vec, +} + +/// 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> { + 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 { + 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> { + let mut chain: Vec = Vec::new(); + let mut seen: HashSet = 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 = 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()); + } +} diff --git a/xtask/src/registry.rs b/xtask/src/registry.rs new file mode 100644 index 0000000..b0c2c1f --- /dev/null +++ b/xtask/src/registry.rs @@ -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, +} + +#[derive(Debug, Clone, Default)] +pub struct Dep { + pub version: Option, + pub path: Option, + pub features: Vec, + pub default_features: Option, + /// 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(deserializer: D) -> Result + 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, + #[serde(default)] + path: Option, + #[serde(default)] + features: Vec, + #[serde(default)] + default_features: Option, + #[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, +} + +#[derive(Deserialize, Default)] +struct SyncSidecar { + #[serde(default)] + no_rebase: Vec, + #[serde(default)] + impls: ImplsSection, +} + +#[derive(Deserialize, Default)] +struct ImplsSection { + #[serde(default)] + known: Vec, +} + +/// 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, + /// 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, +} + +/// 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 { + 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 { + 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 { + 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 = + 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 }) +} diff --git a/xtask/src/render.rs b/xtask/src/render.rs new file mode 100644 index 0000000..798bf28 --- /dev/null +++ b/xtask/src/render.rs @@ -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 { + 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 = 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 = 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::>() + .join(", "); + parts.push(format!("features = [{feats}]")); + } + format!("{name} = {{ {} }}", parts.join(", ")) +} diff --git a/xtask/src/sync.rs b/xtask/src/sync.rs new file mode 100644 index 0000000..1eee3fa --- /dev/null +++ b/xtask/src/sync.rs @@ -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 { + 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> { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 +}