use std::{
collections::HashMap,
error::Error,
fs,
path::{Path, PathBuf},
};
use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value, value};
pub type DynError = Box<dyn Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, DynError>;
#[derive(Debug, Default)]
pub struct SyncReport {
pub changed_manifests: Vec<PathBuf>,
pub unchanged_manifests: Vec<PathBuf>,
pub missing_manifests: Vec<PathBuf>,
pub updated_dependencies: usize,
pub missing_canonical_dependencies: Vec<(PathBuf, String, String)>,
}
#[derive(Debug, Clone, Default, PartialEq)]
struct DepSpec {
branch: Option<String>,
default_features: Option<bool>,
features: Option<Vec<String>>,
git: Option<String>,
package: Option<String>,
path: Option<String>,
rev: Option<String>,
tag: Option<String>,
version: Option<String>,
}
impl DepSpec {
fn needs_inline(&self) -> bool {
self.features.is_some()
|| self.default_features.is_some()
|| self.path.is_some()
|| self.git.is_some()
|| self.tag.is_some()
|| self.rev.is_some()
|| self.branch.is_some()
|| self.package.is_some()
}
}
pub fn sync_subrepos(root_manifest_path: &Path, subrepo_roots: &[PathBuf]) -> Result<SyncReport> {
let canonical = read_canonical_deps(root_manifest_path)?;
let root_dir = root_manifest_path
.parent()
.ok_or_else(|| "root manifest should have a parent directory".to_string())?;
let mut report = SyncReport::default();
for subrepo_root in subrepo_roots {
let manifest_path = subrepo_root.join("Cargo.toml");
if !manifest_path.exists() {
report.missing_manifests.push(manifest_path);
continue;
}
let manifest_dir = manifest_path.parent().ok_or_else(|| {
format!(
"manifest {} should have a parent directory",
manifest_path.display()
)
})?;
let root_prefix = relative_prefix_to_ancestor(manifest_dir, root_dir);
let r = sync_one_manifest(&manifest_path, &canonical, root_prefix.as_deref())?;
report.changed_manifests.extend(r.changed_manifests);
report.unchanged_manifests.extend(r.unchanged_manifests);
report
.missing_canonical_dependencies
.extend(r.missing_canonical_dependencies);
report.updated_dependencies += r.updated_dependencies;
}
Ok(report)
}
fn sync_one_manifest(
manifest_path: &Path,
canonical: &HashMap<String, DepSpec>,
root_prefix: Option<&Path>,
) -> Result<SyncReport> {
let before = fs::read_to_string(manifest_path)
.map_err(|e| format!("failed to read {}: {e}", manifest_path.display()))?;
let mut doc = before
.parse::<DocumentMut>()
.map_err(|e| format!("failed to parse TOML {}: {e}", manifest_path.display()))?;
let mut report = SyncReport::default();
let mut updated = 0usize;
updated += sync_subrepo_workspace_dependencies(
doc.as_table_mut(),
canonical,
manifest_path,
&mut report,
root_prefix,
);
updated += sync_dep_table(
doc.as_table_mut(),
"dependencies",
canonical,
manifest_path,
&mut report,
"",
root_prefix,
);
updated += sync_dep_table(
doc.as_table_mut(),
"dev-dependencies",
canonical,
manifest_path,
&mut report,
"",
root_prefix,
);
updated += sync_dep_table(
doc.as_table_mut(),
"build-dependencies",
canonical,
manifest_path,
&mut report,
"",
root_prefix,
);
updated += sync_target_dep_tables(
doc.as_table_mut(),
canonical,
manifest_path,
&mut report,
root_prefix,
);
let after = doc.to_string();
report.updated_dependencies = updated;
if after != before {
fs::write(manifest_path, after)
.map_err(|e| format!("failed to write {}: {e}", manifest_path.display()))?;
report.changed_manifests.push(manifest_path.to_path_buf());
} else {
report.unchanged_manifests.push(manifest_path.to_path_buf());
}
Ok(report)
}
fn sync_subrepo_workspace_dependencies(
root: &mut Table,
canonical: &HashMap<String, DepSpec>,
manifest_path: &Path,
report: &mut SyncReport,
root_prefix: Option<&Path>,
) -> usize {
let Some(ws_item) = root.get_mut("workspace") else {
return 0;
};
let Some(ws_table) = ws_item.as_table_mut() else {
return 0;
};
sync_dep_table(
ws_table,
"dependencies",
canonical,
manifest_path,
report,
"workspace",
root_prefix,
)
}
fn read_canonical_deps(root_manifest_path: &Path) -> Result<HashMap<String, DepSpec>> {
let contents = fs::read_to_string(root_manifest_path)
.map_err(|e| format!("failed to read {}: {e}", root_manifest_path.display()))?;
let doc = contents
.parse::<DocumentMut>()
.map_err(|e| format!("failed to parse TOML {}: {e}", root_manifest_path.display()))?;
let ws_deps = doc
.as_table()
.get("workspace")
.and_then(Item::as_table)
.and_then(|t| t.get("dependencies"))
.and_then(Item::as_table)
.ok_or_else(|| {
format!(
"root manifest {} must contain [workspace.dependencies]",
root_manifest_path.display()
)
})?;
let mut out: HashMap<String, DepSpec> = HashMap::new();
for (dep_name, item) in ws_deps.iter() {
let spec = parse_dep_item_inline_only(item);
if spec.version.is_some() || spec.needs_inline() {
out.insert(dep_name.to_string(), spec);
}
}
Ok(out)
}
fn parse_dep_item_inline_only(item: &Item) -> DepSpec {
if let Some(v) = item.as_value().and_then(|v| v.as_str()) {
return DepSpec {
version: Some(v.to_string()),
..DepSpec::default()
};
}
if let Some(inline) = item.as_inline_table() {
return DepSpec {
version: inline
.get("version")
.and_then(|v| v.as_str())
.map(str::to_string),
features: parse_features(inline.get("features")),
default_features: inline.get("default-features").and_then(|v| v.as_bool()),
path: inline
.get("path")
.and_then(|v| v.as_str())
.map(str::to_string),
git: inline
.get("git")
.and_then(|v| v.as_str())
.map(str::to_string),
tag: inline
.get("tag")
.and_then(|v| v.as_str())
.map(str::to_string),
rev: inline
.get("rev")
.and_then(|v| v.as_str())
.map(str::to_string),
branch: inline
.get("branch")
.and_then(|v| v.as_str())
.map(str::to_string),
package: inline
.get("package")
.and_then(|v| v.as_str())
.map(str::to_string),
};
}
DepSpec::default()
}
fn parse_features(v: Option<&Value>) -> Option<Vec<String>> {
let arr = v?.as_array()?;
let mut out = Vec::new();
for val in arr.iter() {
out.push(val.as_str()?.to_string());
}
Some(out)
}
fn sync_target_dep_tables(
root: &mut Table,
canonical: &HashMap<String, DepSpec>,
manifest_path: &Path,
report: &mut SyncReport,
root_prefix: Option<&Path>,
) -> usize {
let Some(target_item) = root.get_mut("target") else {
return 0;
};
let Some(target_table) = target_item.as_table_mut() else {
return 0;
};
let mut updated = 0usize;
for (target_key, per_target_item) in target_table.iter_mut() {
let Some(per_target_table) = per_target_item.as_table_mut() else {
continue;
};
let prefix = format!("target.{target_key}");
updated += sync_dep_table(
per_target_table,
"dependencies",
canonical,
manifest_path,
report,
&prefix,
root_prefix,
);
updated += sync_dep_table(
per_target_table,
"dev-dependencies",
canonical,
manifest_path,
report,
&prefix,
root_prefix,
);
updated += sync_dep_table(
per_target_table,
"build-dependencies",
canonical,
manifest_path,
report,
&prefix,
root_prefix,
);
}
updated
}
fn sync_dep_table(
root: &mut Table,
table_name: &str,
canonical: &HashMap<String, DepSpec>,
manifest_path: &Path,
report: &mut SyncReport,
prefix: &str,
root_prefix: Option<&Path>,
) -> usize {
let Some(item) = root.get_mut(table_name) else {
return 0;
};
let Some(deps_table) = item.as_table_like_mut() else {
return 0;
};
let keys: Vec<String> = deps_table.iter().map(|(k, _)| k.to_string()).collect();
let mut updated = 0usize;
for dep in keys {
let Some(dep_item) = deps_table.get_mut(&dep) else {
continue;
};
let Some(canon) = canonical.get(&dep) else {
let table_path = if prefix.is_empty() {
table_name.to_string()
} else {
format!("{prefix}.{table_name}")
};
report.missing_canonical_dependencies.push((
manifest_path.to_path_buf(),
table_path,
dep,
));
continue;
};
if let Some((from, to)) = apply_canonical_to_item(dep_item, canon, root_prefix) {
updated += 1;
eprintln!(
" - {}: {} → {}",
dep,
fmt_dep_spec(&from),
fmt_dep_spec(&to),
);
}
}
updated
}
fn apply_canonical_to_item(
dep_item: &mut Item,
canon: &DepSpec,
root_prefix: Option<&Path>,
) -> Option<(DepSpec, DepSpec)> {
let before_spec = dep_spec_from_item(dep_item)?;
if dep_item.as_value().and_then(|v| v.as_str()).is_some() {
if canon_requires_inline_for_source(canon) {
let inline = to_inline_table(canon, root_prefix);
*dep_item = Item::Value(Value::InlineTable(inline));
} else if let Some(version) = canon.version.as_deref() {
*dep_item = value(version);
} else {
}
let after_spec = dep_spec_from_item(dep_item)?;
return if after_spec != before_spec {
Some((before_spec, after_spec))
} else {
None
};
}
let inline = dep_item.as_inline_table_mut()?;
if inline
.get("workspace")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return None;
}
match canon.version.as_deref() {
Some(v) => {
let _ = set_k_str(inline, "version", v);
}
None => {
if canon_requires_inline_for_source(canon) {
let _ = remove_key_if_present(inline, "version");
}
}
}
if let Some(features) = canon.features.as_ref() {
let _ = set_k_features(inline, features);
}
if let Some(df) = canon.default_features {
let _ = set_k_bool(inline, "default-features", df);
}
let _ = sync_source_keys_inline(inline, canon, root_prefix);
let after_spec = dep_spec_from_item(dep_item)?;
if after_spec != before_spec {
Some((before_spec, after_spec))
} else {
None
}
}
fn dep_spec_from_item(item: &Item) -> Option<DepSpec> {
if let Some(v) = item.as_value().and_then(|v| v.as_str()) {
return Some(DepSpec {
version: Some(v.to_string()),
..DepSpec::default()
});
}
let inline = item.as_inline_table()?;
if inline
.get("workspace")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return None;
}
Some(DepSpec {
version: inline
.get("version")
.and_then(|v| v.as_str())
.map(str::to_string),
features: parse_features(inline.get("features")),
default_features: inline.get("default-features").and_then(|v| v.as_bool()),
path: inline
.get("path")
.and_then(|v| v.as_str())
.map(str::to_string),
git: inline
.get("git")
.and_then(|v| v.as_str())
.map(str::to_string),
tag: inline
.get("tag")
.and_then(|v| v.as_str())
.map(str::to_string),
rev: inline
.get("rev")
.and_then(|v| v.as_str())
.map(str::to_string),
branch: inline
.get("branch")
.and_then(|v| v.as_str())
.map(str::to_string),
package: inline
.get("package")
.and_then(|v| v.as_str())
.map(str::to_string),
})
}
fn canon_requires_inline_for_source(canon: &DepSpec) -> bool {
canon.path.is_some()
|| canon.git.is_some()
|| canon.tag.is_some()
|| canon.rev.is_some()
|| canon.branch.is_some()
|| canon.package.is_some()
}
fn to_inline_table(canon: &DepSpec, root_prefix: Option<&Path>) -> InlineTable {
let mut inline = InlineTable::default();
if let Some(version) = canon.version.as_deref() {
inline.insert("version", Value::from(version));
}
if let Some(features) = canon.features.as_ref() {
inline.insert("features", Value::Array(features_to_array(features)));
}
if let Some(df) = canon.default_features {
inline.insert("default-features", Value::from(df));
}
if let Some(path) = canon.path.as_deref() {
let rebased = rebase_path_for_subrepo(path, root_prefix);
inline.insert("path", Value::from(rebased.as_str()));
}
if let Some(git) = canon.git.as_deref() {
inline.insert("git", Value::from(git));
}
if let Some(tag) = canon.tag.as_deref() {
inline.insert("tag", Value::from(tag));
}
if let Some(rev) = canon.rev.as_deref() {
inline.insert("rev", Value::from(rev));
}
if let Some(branch) = canon.branch.as_deref() {
inline.insert("branch", Value::from(branch));
}
if let Some(package) = canon.package.as_deref() {
inline.insert("package", Value::from(package));
}
inline
}
fn sync_source_keys_inline(
inline: &mut InlineTable,
canon: &DepSpec,
root_prefix: Option<&Path>,
) -> bool {
let mut changed = false;
match canon.path.as_deref() {
Some(p) => {
let rebased = rebase_path_for_subrepo(p, root_prefix);
changed |= set_k_str(inline, "path", rebased.as_str());
}
None => {
changed |= remove_key_if_present(inline, "path");
}
}
changed |= sync_opt_str(inline, "git", canon.git.as_deref());
changed |= sync_opt_str(inline, "tag", canon.tag.as_deref());
changed |= sync_opt_str(inline, "rev", canon.rev.as_deref());
changed |= sync_opt_str(inline, "branch", canon.branch.as_deref());
changed |= sync_opt_str(inline, "package", canon.package.as_deref());
changed
}
fn sync_opt_str(inline: &mut InlineTable, key: &str, desired: Option<&str>) -> bool {
match desired {
Some(v) => set_k_str(inline, key, v),
None => remove_key_if_present(inline, key),
}
}
fn remove_key_if_present(inline: &mut InlineTable, key: &str) -> bool {
if inline.get(key).is_some() {
inline.remove(key);
true
} else {
false
}
}
fn set_k_str(inline: &mut InlineTable, key: &str, val: &str) -> bool {
if inline.get(key).and_then(|v| v.as_str()) == Some(val) {
return false;
}
inline.insert(key, Value::from(val));
true
}
fn set_k_bool(inline: &mut InlineTable, key: &str, val: bool) -> bool {
if inline.get(key).and_then(|v| v.as_bool()) == Some(val) {
return false;
}
inline.insert(key, Value::from(val));
true
}
fn set_k_features(inline: &mut InlineTable, features: &[String]) -> bool {
let desired = features_to_array(features);
let current = inline.get("features").and_then(|v| v.as_array());
if let Some(cur) = current
&& arrays_equal_str(cur, &desired)
{
return false;
}
inline.insert("features", Value::Array(desired));
true
}
fn features_to_array(features: &[String]) -> Array {
let mut arr = Array::default();
for f in features {
arr.push(Value::from(f.as_str()));
}
arr
}
fn arrays_equal_str(a: &Array, b: &Array) -> bool {
if a.len() != b.len() {
return false;
}
for (va, vb) in a.iter().zip(b.iter()) {
if va.as_str() != vb.as_str() {
return false;
}
}
true
}
fn relative_prefix_to_ancestor(from_dir: &Path, ancestor_dir: &Path) -> Option<PathBuf> {
let mut cur = from_dir;
let mut prefix = PathBuf::new();
while cur != ancestor_dir {
let parent = cur.parent()?;
prefix.push("..");
cur = parent;
}
Some(prefix)
}
fn rebase_path_for_subrepo(canonical_path: &str, root_prefix: Option<&Path>) -> String {
let Some(prefix) = root_prefix else {
return canonical_path.to_string();
};
let p = Path::new(canonical_path);
if p.is_absolute() {
return canonical_path.to_string();
}
let rebased = prefix.join(p);
rebased.to_string_lossy().replace('\\', "/")
}
fn fmt_dep_spec(spec: &DepSpec) -> String {
if spec.version.is_some()
&& spec.features.is_none()
&& spec.default_features.is_none()
&& spec.path.is_none()
&& spec.git.is_none()
&& spec.tag.is_none()
&& spec.rev.is_none()
&& spec.branch.is_none()
&& spec.package.is_none()
{
return spec.version.clone().unwrap();
}
let mut parts = Vec::new();
if let Some(v) = &spec.version {
parts.push(format!("version={v}"));
}
if let Some(p) = &spec.path {
parts.push(format!("path={p}"));
}
if let Some(g) = &spec.git {
parts.push(format!("git={g}"));
}
if let Some(t) = &spec.tag {
parts.push(format!("tag={t}"));
}
if let Some(r) = &spec.rev {
parts.push(format!("rev={r}"));
}
if let Some(b) = &spec.branch {
parts.push(format!("branch={b}"));
}
if let Some(pkg) = &spec.package {
parts.push(format!("package={pkg}"));
}
if let Some(df) = spec.default_features {
parts.push(format!("default-features={df}"));
}
if let Some(f) = &spec.features {
parts.push(format!("features={:?}", f));
}
format!("{{ {} }}", parts.join(", "))
}