use anyhow::{Context, Result};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
enum Source {
Git(String),
Path(String),
}
impl Serialize for Source {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(Some(1))?;
match self {
Source::Git(url) => map.serialize_entry("Git", url)?,
Source::Path(path) => map.serialize_entry("Path", path)?,
}
map.end()
}
}
impl<'de> Deserialize<'de> for Source {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let map = HashMap::<String, String>::deserialize(deserializer)?;
if let Some(git_url) = map.get("Git") {
Ok(Source::Git(git_url.clone()))
} else if let Some(path) = map.get("Path") {
Ok(Source::Path(path.clone()))
} else {
Err(serde::de::Error::custom("Expected 'Git' or 'Path' key in source"))
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct Package {
revision: Option<String>,
version: Option<String>,
source: Source,
dependencies: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct LockFile {
packages: BTreeMap<String, Package>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
enum DependencySpec {
Simple(String),
Detailed(HashMap<String, serde_yaml::Value>),
}
#[derive(Debug, Deserialize, Serialize)]
struct BenderYml {
dependencies: Option<HashMap<String, DependencySpec>>,
}
#[derive(Debug, Deserialize, Serialize)]
struct DotBenderYml {
overrides: Option<HashMap<String, HashMap<String, serde_yaml::Value>>>,
}
fn convert_url(git_url: &str) -> String {
if git_url.contains("github.com/pulp-platform") {
if let Some(repo_name) = git_url.split('/').last() {
return format!("git@code.ihep.ac.cn:heris/heris-platform/{}", repo_name);
}
}
git_url.to_string()
}
fn extract_dependencies_from_lock(lock_data: &LockFile) -> HashMap<String, HashMap<String, serde_yaml::Value>> {
let mut dependencies = HashMap::new();
for (pkg_name, pkg_info) in &lock_data.packages {
match &pkg_info.source {
Source::Git(git_url) => {
let converted_url = convert_url(git_url);
let mut dep_info = HashMap::new();
dep_info.insert("git".to_string(), serde_yaml::Value::String(converted_url));
if let Some(version) = &pkg_info.version {
dep_info.insert("version".to_string(), serde_yaml::to_value(version).unwrap());
} else if let Some(revision) = &pkg_info.revision {
dep_info.insert("rev".to_string(), serde_yaml::Value::String(revision.clone()));
}
dependencies.insert(pkg_name.clone(), dep_info);
}
Source::Path(path) => {
let mut dep_info = HashMap::new();
dep_info.insert("path".to_string(), serde_yaml::Value::String(path.clone()));
dependencies.insert(pkg_name.clone(), dep_info);
}
}
}
dependencies
}
fn extract_dependencies_from_yml(yml_data: &BenderYml) -> HashSet<String> {
if let Some(deps) = &yml_data.dependencies {
deps.keys().cloned().collect()
} else {
HashSet::new()
}
}
fn extract_overrides_from_bender_yml(bender_yml_data: &DotBenderYml) -> HashMap<String, HashMap<String, serde_yaml::Value>> {
if let Some(overrides) = &bender_yml_data.overrides {
overrides.clone()
} else {
HashMap::new()
}
}
fn compare_versions(v1: &Option<String>, v2: &Option<String>) -> i8 {
match (v1, v2) {
(None, None) => 0,
(None, Some(_)) => -1,
(Some(_), None) => 1,
(Some(v1_str), Some(v2_str)) => {
let v1_parts: Vec<&str> = v1_str.split('.').collect();
let v2_parts: Vec<&str> = v2_str.split('.').collect();
let max_len = v1_parts.len().max(v2_parts.len());
for i in 0..max_len {
let p1 = v1_parts.get(i).and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
let p2 = v2_parts.get(i).and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
if p1 > p2 {
return 1;
} else if p1 < p2 {
return -1;
}
}
0
}
}
}
fn find_missing_dependencies(
lock_deps: &HashMap<String, HashMap<String, serde_yaml::Value>>,
yml_deps: &HashSet<String>,
existing_overrides: &HashMap<String, HashMap<String, serde_yaml::Value>>,
) -> HashMap<String, HashMap<String, serde_yaml::Value>> {
let mut missing_deps = HashMap::new();
for (dep_name, dep_info) in lock_deps {
if yml_deps.contains(dep_name) {
continue;
}
if let Some(existing_info) = existing_overrides.get(dep_name) {
if dep_info.contains_key("git") && existing_info.contains_key("git") {
let new_version = dep_info.get("version").and_then(|v| {
if let serde_yaml::Value::String(s) = v {
Some(s.clone())
} else {
v.as_f64().map(|f| f.to_string())
}
});
let existing_version = existing_info.get("version").and_then(|v| {
if let serde_yaml::Value::String(s) = v {
Some(s.clone())
} else {
v.as_f64().map(|f| f.to_string())
}
});
if compare_versions(&new_version, &existing_version) > 0 {
missing_deps.insert(dep_name.clone(), dep_info.clone());
}
} else if dep_info.contains_key("git") && existing_info.contains_key("path") {
missing_deps.insert(dep_name.clone(), dep_info.clone());
}
} else {
missing_deps.insert(dep_name.clone(), dep_info.clone());
}
}
missing_deps
}
fn format_dependency_line(
name: &str,
dep_info: &HashMap<String, serde_yaml::Value>,
max_name_len: usize,
max_url_len: usize,
) -> String {
let name_padding = " ".repeat(max_name_len.saturating_sub(name.len()));
if let Some(serde_yaml::Value::String(path)) = dep_info.get("path") {
let url_part = format!(r#"{{ path: "{}" "#, path);
let url_padding = " ".repeat(max_url_len.saturating_sub(url_part.len()));
format!(" {}:{} {}{}}}", name, name_padding, url_part, url_padding)
} else if let Some(serde_yaml::Value::String(git_url)) = dep_info.get("git") {
if let Some(version) = dep_info.get("version") {
let version_str = match version {
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Number(n) => n.to_string(),
_ => format!("{:?}", version),
};
let git_part = format!(r#"{{ git: "{}","#, git_url);
let url_padding = " ".repeat(max_url_len.saturating_sub(git_part.len()));
format!(" {}:{} {}{} version: {} }}", name, name_padding, git_part, url_padding, version_str)
} else if let Some(serde_yaml::Value::String(rev)) = dep_info.get("rev") {
let git_part = format!(r#"{{ git: "{}","#, git_url);
let url_padding = " ".repeat(max_url_len.saturating_sub(git_part.len()));
format!(r#" {}:{} {}{} rev: "{}" }}"#, name, name_padding, git_part, url_padding, rev)
} else {
let git_part = format!(r#"{{ git: "{}"#, git_url);
let url_padding = " ".repeat(max_url_len.saturating_sub(git_part.len()));
format!(" {}:{} {}{} }}", name, name_padding, git_part, url_padding)
}
} else {
format!(" {}:{} {{}}", name, name_padding)
}
}
fn generate_overrides_lines(all_overrides: &HashMap<String, HashMap<String, serde_yaml::Value>>) -> Vec<String> {
if all_overrides.is_empty() {
return Vec::new();
}
let max_name_len = all_overrides.keys().map(|k| k.len()).max().unwrap_or(0);
let mut max_url_len = 0;
for dep_info in all_overrides.values() {
let url_part_len = if let Some(serde_yaml::Value::String(path)) = dep_info.get("path") {
format!(r#"{{ path: "{}" "#, path).len()
} else if let Some(serde_yaml::Value::String(git_url)) = dep_info.get("git") {
if dep_info.contains_key("version") || dep_info.contains_key("rev") {
format!(r#"{{ git: "{}","#, git_url).len()
} else {
format!(r#"{{ git: "{}"#, git_url).len()
}
} else {
0
};
max_url_len = max_url_len.max(url_part_len);
}
let mut lines = Vec::new();
let mut names: Vec<&String> = all_overrides.keys().collect();
names.sort_by(|left, right| {
left.to_ascii_lowercase()
.cmp(&right.to_ascii_lowercase())
.then_with(|| left.cmp(right))
});
for name in names {
let dep_info = &all_overrides[name];
lines.push(format_dependency_line(name, dep_info, max_name_len, max_url_len));
}
lines
}
fn update_bender_yml_overrides(
bender_yml_text: &str,
existing_overrides: &HashMap<String, HashMap<String, serde_yaml::Value>>,
new_overrides: &HashMap<String, HashMap<String, serde_yaml::Value>>,
) -> String {
let mut all_overrides = existing_overrides.clone();
all_overrides.extend(new_overrides.clone());
let override_lines = generate_overrides_lines(&all_overrides);
if override_lines.is_empty() {
return bender_yml_text.to_string();
}
let new_overrides_section = format!("overrides:\n{}", override_lines.join("\n"));
let lines: Vec<&str> = bender_yml_text.lines().collect();
let mut result = Vec::new();
let mut in_overrides = false;
let mut overrides_found = false;
for line in lines {
if line.starts_with("overrides:") {
result.push(new_overrides_section.as_str());
in_overrides = true;
overrides_found = true;
} else if in_overrides {
if !line.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
in_overrides = false;
result.push(line);
}
} else {
result.push(line);
}
}
if !overrides_found {
result.push("\n");
result.push(new_overrides_section.as_str());
}
result.join("\n")
}
pub fn convert(
bendis_dir: &Path,
root_dir: &Path,
) -> Result<bool> {
let lock_path = bendis_dir.join("Bender.lock");
let yml_path = bendis_dir.join("Bender.yml");
let bender_yml_path = bendis_dir.join(".bender.yml");
let lock_content = fs::read_to_string(&lock_path)
.context("Failed to read bendis_workspace/Bender.lock")?;
let mut lock_data: LockFile = serde_yaml::from_str(&lock_content)
.context("Failed to parse Bender.lock")?;
normalize_lock_paths(&mut lock_data, bendis_dir, root_dir)?;
let workspace_lock = serde_yaml::to_string(&lock_data)
.context("Failed to serialize normalized bendis_workspace/Bender.lock")?;
fs::write(&lock_path, workspace_lock)
.context("Failed to write normalized bendis_workspace/Bender.lock")?;
let yml_content = fs::read_to_string(&yml_path)
.context("Failed to read bendis_workspace/Bender.yml")?;
let yml_data: BenderYml = serde_yaml::from_str(&yml_content)
.unwrap_or(BenderYml { dependencies: None });
let bender_yml_text = fs::read_to_string(&bender_yml_path)
.context("Failed to read bendis_workspace/.bender.yml")?;
let bender_yml_data: DotBenderYml = serde_yaml::from_str(&bender_yml_text)
.unwrap_or(DotBenderYml { overrides: None });
let lock_deps = extract_dependencies_from_lock(&lock_data);
let yml_deps = extract_dependencies_from_yml(&yml_data);
let existing_overrides = extract_overrides_from_bender_yml(&bender_yml_data);
let missing_deps = find_missing_dependencies(&lock_deps, &yml_deps, &existing_overrides);
let updated_bender_yml = update_bender_yml_overrides(&bender_yml_text, &existing_overrides, &missing_deps);
let mut effective_overrides = existing_overrides;
effective_overrides.extend(missing_deps);
let mut root_lock_data = lock_data.clone();
for (package, package_info) in &mut root_lock_data.packages {
let Some(override_info) = effective_overrides.get(package) else {
continue;
};
let Some(serde_yaml::Value::String(git_url)) = override_info.get("git") else {
continue;
};
if matches!(package_info.source, Source::Git(_)) {
package_info.source = Source::Git(git_url.clone());
}
}
let updated_lock = serde_yaml::to_string(&root_lock_data)
.context("Failed to serialize converted Bender.lock")?;
let root_yml_path = root_dir.join("Bender.yml");
let output_path = root_dir.join(".bender.yml");
let root_lock_path = root_dir.join("Bender.lock");
let changed = fs::read_to_string(&root_yml_path).ok().as_deref() != Some(&yml_content)
|| fs::read_to_string(&output_path).ok().as_deref() != Some(&updated_bender_yml)
|| fs::read_to_string(&root_lock_path).ok().as_deref() != Some(&updated_lock);
fs::write(&root_yml_path, &yml_content)
.context("Failed to copy Bender.yml to root")?;
fs::write(&output_path, updated_bender_yml)
.context("Failed to write .bender.yml to root")?;
fs::write(&root_lock_path, updated_lock)
.context("Failed to write Bender.lock to root")?;
Ok(changed)
}
fn normalize_lock_paths(lock: &mut LockFile, bendis_dir: &Path, root_dir: &Path) -> Result<()> {
let bendis_dir = bendis_dir
.canonicalize()
.with_context(|| format!("Failed to resolve {}", bendis_dir.display()))?;
let root_dir = root_dir
.canonicalize()
.with_context(|| format!("Failed to resolve {}", root_dir.display()))?;
for (package, package_info) in &mut lock.packages {
let Source::Path(path) = &mut package_info.source else {
continue;
};
let absolute = Path::new(path);
if !absolute.is_absolute() {
continue;
}
let resolved = absolute
.canonicalize()
.unwrap_or_else(|_| absolute.to_path_buf());
let relative = resolved
.strip_prefix(&bendis_dir)
.or_else(|_| resolved.strip_prefix(&root_dir))
.with_context(|| {
format!(
"local dependency path for {package} is outside the project: {path}"
)
})?;
*path = relative.to_string_lossy().replace('\\', "/");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{convert, generate_overrides_lines};
use serde_yaml::Value;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn convert_reports_when_hard_config_is_replaced() {
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let base: PathBuf = std::env::temp_dir().join(format!("bendis-convert-{nonce}"));
let workspace = base.join("bendis_workspace");
fs::create_dir_all(&workspace).unwrap();
fs::write(workspace.join("Bender.yml"), "dependencies: {}\n").unwrap();
fs::write(workspace.join(".bender.yml"), "overrides: {}\n").unwrap();
fs::write(workspace.join("Bender.lock"), "packages: {}\n").unwrap();
fs::write(base.join("Bender.yml"), "dependencies:\n core: { path: '../aegisrtl/core' }\n").unwrap();
fs::write(base.join(".bender.yml"), "overrides:\n core: { path: '../aegisrtl/core' }\n").unwrap();
assert!(convert(&workspace, &base).unwrap());
assert!(!convert(&workspace, &base).unwrap());
fs::remove_dir_all(base).unwrap();
}
#[test]
fn convert_writes_exact_lock_with_effective_override_sources() {
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let base: PathBuf = std::env::temp_dir().join(format!("bendis-lock-convert-{nonce}"));
let workspace = base.join("bendis_workspace");
fs::create_dir_all(&workspace).unwrap();
fs::write(
workspace.join("Bender.yml"),
"dependencies:\n core: { git: 'https://github.com/pulp-platform/core.git', version: 1.2 }\n",
)
.unwrap();
fs::write(
workspace.join(".bender.yml"),
"overrides:\n core: { git: 'git@code.ihep.ac.cn:heris/heris-platform/core.git', version: 1.2 }\n",
)
.unwrap();
fs::write(
workspace.join("Bender.lock"),
"packages:\n core:\n revision: abc123\n version: 1.2.3\n source:\n Git: https://github.com/pulp-platform/core.git\n dependencies:\n - helper\n helper:\n revision: def456\n version: null\n source:\n Git: https://github.com/pulp-platform/helper.git\n dependencies: []\n",
)
.unwrap();
convert(&workspace, &base).unwrap();
let lock_text = fs::read_to_string(base.join("Bender.lock")).unwrap();
assert!(lock_text.find(" core:").unwrap() < lock_text.find(" helper:").unwrap());
let lock: Value = serde_yaml::from_str(&lock_text).unwrap();
assert_eq!(
lock["packages"]["core"]["source"]["Git"],
"git@code.ihep.ac.cn:heris/heris-platform/core.git"
);
assert_eq!(lock["packages"]["core"]["revision"], "abc123");
assert_eq!(lock["packages"]["core"]["version"], "1.2.3");
assert_eq!(lock["packages"]["core"]["dependencies"][0], "helper");
assert_eq!(
lock["packages"]["helper"]["source"]["Git"],
"git@code.ihep.ac.cn:heris/heris-platform/helper.git"
);
assert_eq!(lock["packages"]["helper"]["revision"], "def456");
let workspace_lock: Value = serde_yaml::from_str(
&fs::read_to_string(workspace.join("Bender.lock")).unwrap(),
)
.unwrap();
assert_eq!(
workspace_lock["packages"]["core"]["source"]["Git"],
"https://github.com/pulp-platform/core.git"
);
fs::remove_dir_all(base).unwrap();
}
#[test]
fn convert_rebases_project_local_absolute_lock_paths() {
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let base: PathBuf = std::env::temp_dir().join(format!("bendis-path-rebase-{nonce}"));
let workspace = base.join("bendis_workspace");
let gpio = base.join("hw/vendored_ips/gpio");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&gpio).unwrap();
fs::write(
workspace.join("Bender.yml"),
"dependencies:\n gpio: { path: 'hw/vendored_ips/gpio' }\n",
)
.unwrap();
fs::write(workspace.join(".bender.yml"), "overrides: {}\n").unwrap();
fs::write(
workspace.join("Bender.lock"),
format!(
"packages:\n gpio:\n revision: null\n version: null\n source:\n Path: {}\n dependencies: []\n",
gpio.display()
),
)
.unwrap();
convert(&workspace, &base).unwrap();
let lock: Value =
serde_yaml::from_str(&fs::read_to_string(base.join("Bender.lock")).unwrap()).unwrap();
assert_eq!(
lock["packages"]["gpio"]["source"]["Path"],
"hw/vendored_ips/gpio"
);
fs::remove_dir_all(base).unwrap();
}
#[test]
fn convert_rebases_absolute_lock_paths_with_relative_project_directories() {
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let base = PathBuf::from("target").join(format!("bendis-relative-path-{nonce}"));
let workspace = base.join("bendis_workspace");
let gpio = base.join("hw/vendored_ips/gpio");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&gpio).unwrap();
let absolute_gpio = gpio.canonicalize().unwrap();
fs::write(
workspace.join("Bender.yml"),
"dependencies:\n gpio: { path: 'hw/vendored_ips/gpio' }\n",
)
.unwrap();
fs::write(workspace.join(".bender.yml"), "overrides: {}\n").unwrap();
fs::write(
workspace.join("Bender.lock"),
format!(
"packages:\n gpio:\n revision: null\n version: null\n source:\n Path: {}\n dependencies: []\n",
absolute_gpio.display()
),
)
.unwrap();
convert(&workspace, &base).unwrap();
let lock: Value =
serde_yaml::from_str(&fs::read_to_string(base.join("Bender.lock")).unwrap()).unwrap();
assert_eq!(
lock["packages"]["gpio"]["source"]["Path"],
"hw/vendored_ips/gpio"
);
let workspace_lock: Value = serde_yaml::from_str(
&fs::read_to_string(workspace.join("Bender.lock")).unwrap(),
)
.unwrap();
assert_eq!(
workspace_lock["packages"]["gpio"]["source"]["Path"],
"hw/vendored_ips/gpio"
);
fs::remove_dir_all(base).unwrap();
}
#[test]
fn generated_overrides_are_sorted_by_dependency_name() {
let mut overrides = HashMap::new();
for name in ["zeta", "theta", "omega", "gamma", "delta", "beta", "alpha"] {
overrides.insert(
name.to_string(),
HashMap::from([(
"path".to_string(),
Value::String(format!("hw/{name}")),
)]),
);
}
let lines = generate_overrides_lines(&overrides);
let names: Vec<&str> = lines
.iter()
.map(|line| line.trim_start().split(':').next().unwrap())
.collect();
assert_eq!(
names,
vec!["alpha", "beta", "delta", "gamma", "omega", "theta", "zeta"]
);
}
}