diff --git a/Cargo.lock b/Cargo.lock index 1f436e3..6e754e1 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,7 +418,7 @@ checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" [[package]] name = "depot" -version = "0.31.0" +version = "0.31.1" dependencies = [ "anyhow", "ar", @@ -3090,9 +3090,9 @@ dependencies = [ [[package]] name = "zip" -version = "8.3.1" +version = "8.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c546feb4481b0fbafb4ef0d79b6204fc41c6f9884b1b73b1d73f82442fc0845" +checksum = "7756d0206d058333667493c4014f545f4b9603c4330ccd6d9b3f86dcab59f7d9" dependencies = [ "aes", "bzip2", diff --git a/Cargo.toml b/Cargo.toml index 6d70f30..5cc8a20 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "depot" -version = "0.31.0" +version = "0.31.1" edition = "2024" [lints.rust] @@ -27,7 +27,7 @@ toml = "1.0.6" url = "2.5.8" walkdir = "2.5.0" xz2 = "0.1.7" -zip = "8.3.1" +zip = "8.4.0" zstd = { version = "0.13.3", features = ["zstdmt"] } inquire = "0.9.4" md5 = "0.8.0" diff --git a/meson.build b/meson.build index c5a5b85..ae75bc6 100644 --- a/meson.build +++ b/meson.build @@ -1,6 +1,6 @@ project( 'depot', - version: '0.31.0', + version: '0.31.1', meson_version: '>=0.60.0', default_options: ['buildtype=release'], ) diff --git a/src/builder/custom.rs b/src/builder/custom.rs index 25db419..7bcc180 100755 --- a/src/builder/custom.rs +++ b/src/builder/custom.rs @@ -117,6 +117,19 @@ pub fn build( "DEPOT_PRIMARY_DESTDIR", install_destdir.to_string_lossy().into_owned(), ); + let starbuild_workdir = if spec.sources().is_empty() { + src_dir.to_path_buf() + } else { + src_dir + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| src_dir.to_path_buf()) + }; + crate::builder::set_env_var( + &mut env_vars, + "DEPOT_STARBUILD_WORKDIR", + starbuild_workdir.to_string_lossy().into_owned(), + ); add_output_destdir_envs(spec, &install_destdir, &mut env_vars); // Ensure build script path is absolute for when we are in a sub-build-dir diff --git a/src/cli.rs b/src/cli.rs index 425fb38..86ee9c7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -240,6 +240,17 @@ pub struct MakeSpecArgs { pub output: Option, } +#[derive(Debug, Clone, Args)] +pub struct ConvertArgs { + /// Path to the legacy STARBUILD file + #[arg(default_value = "STARBUILD")] + pub input: PathBuf, + + /// Output TOML file path (defaults to .toml beside the STARBUILD) + #[arg(short, long)] + pub output: Option, +} + #[derive(Debug, Clone, Args)] pub struct InternalArgs { #[command(subcommand)] @@ -277,6 +288,8 @@ pub enum Commands { GenerateArtifacts(GenerateArtifactsArgs), /// Create a new package specification interactively MakeSpec(MakeSpecArgs), + /// Convert a legacy STARBUILD into a Depot package spec + Convert(ConvertArgs), #[command(hide = true)] Internal(InternalArgs), } @@ -430,7 +443,8 @@ pub enum RepoKindArg { #[cfg(test)] mod tests { use super::{ - BuildArgs, Cli, Commands, InstallArgs, RepoArgs, RepoCommands, SearchArgs, UpdateArgs, + BuildArgs, Cli, Commands, ConvertArgs, InstallArgs, RepoArgs, RepoCommands, SearchArgs, + UpdateArgs, }; use clap::{CommandFactory, Parser}; use std::path::PathBuf; @@ -517,6 +531,31 @@ mod tests { } } + #[test] + fn convert_accepts_default_input() { + let cli = Cli::try_parse_from(["depot", "convert"]).unwrap(); + match cli.command { + Commands::Convert(ConvertArgs { input, output }) => { + assert_eq!(input, PathBuf::from("STARBUILD")); + assert!(output.is_none()); + } + _ => panic!("expected convert command"), + } + } + + #[test] + fn convert_accepts_custom_output() { + let cli = Cli::try_parse_from(["depot", "convert", "legacy/STARBUILD", "-o", "pkg.toml"]) + .unwrap(); + match cli.command { + Commands::Convert(ConvertArgs { input, output }) => { + assert_eq!(input, PathBuf::from("legacy/STARBUILD")); + assert_eq!(output, Some(PathBuf::from("pkg.toml"))); + } + _ => panic!("expected convert command"), + } + } + #[test] fn build_cleanup_deps_flag_is_parsed() { let cli = Cli::try_parse_from(["depot", "build", "--cleanup-deps", "pkg.toml"]).unwrap(); diff --git a/src/commands.rs b/src/commands.rs index 55cfa4c..08f80c8 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,6 +1,7 @@ use crate::cli::{ - BuildArgs, Cli, Commands, ConfigArgs, InfoArgs, InstallArgs, InternalCommands, ListArgs, - OwnsArgs, RemoveArgs, RepoArgs, RepoCommands, RepoKindArg, SearchArgs, SignArgs, UpdateArgs, + BuildArgs, Cli, Commands, ConfigArgs, ConvertArgs, InfoArgs, InstallArgs, InternalCommands, + ListArgs, OwnsArgs, RemoveArgs, RepoArgs, RepoCommands, RepoKindArg, SearchArgs, SignArgs, + UpdateArgs, }; use crate::{ builder, cli_assets, config, cross, db, deps, index, install, locking, package, planner, @@ -63,6 +64,7 @@ fn command_rootfs(command: &Commands) -> Option<&Path> { Commands::Repo(args) => Some(repo_command_rootfs(&args.command)), Commands::Config(args) => Some(&args.rootfs_args.rootfs), Commands::Check(_) + | Commands::Convert(_) | Commands::GenerateArtifacts(_) | Commands::MakeSpec(_) | Commands::Internal(_) => None, @@ -76,6 +78,7 @@ fn command_assume_yes(command: &Commands) -> bool { Commands::Build(args) => args.prompt_args.yes, Commands::Update(args) => args.prompt_args.yes, Commands::Check(_) + | Commands::Convert(_) | Commands::Info(_) | Commands::Search(_) | Commands::Owns(_) @@ -4669,6 +4672,7 @@ pub fn run(cli: Cli) -> Result<()> { | Commands::Repo(_) | Commands::Config(_) | Commands::GenerateArtifacts(_) + | Commands::Convert(_) | Commands::MakeSpec(_) | Commands::Internal(_) => false, }; @@ -5901,6 +5905,58 @@ pub fn run(cli: Cli) -> Result<()> { output_path.display() )); } + Commands::Convert(ConvertArgs { input, output }) => { + let converted = package::convert_starbuild_file(&input, output.as_deref())?; + let mut outputs = vec![converted.output_path.clone()]; + if let Some(build_script_path) = &converted.build_script_path { + outputs.push(build_script_path.clone()); + } + + let existing: Vec<_> = outputs + .iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect(); + if !existing.is_empty() { + ui::warn(format!( + "Generated files already exist: {}", + existing.join(", ") + )); + if !ui::prompt_yes_no("Overwrite them?", false)? { + anyhow::bail!("Aborted"); + } + } + + fs::write(&converted.output_path, converted.toml) + .with_context(|| format!("Failed to write {}", converted.output_path.display()))?; + if let (Some(build_script), Some(build_script_path)) = + (converted.build_script, converted.build_script_path) + { + fs::write(&build_script_path, build_script) + .with_context(|| format!("Failed to write {}", build_script_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&build_script_path) + .with_context(|| format!("Failed to stat {}", build_script_path.display()))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&build_script_path, perms).with_context(|| { + format!("Failed to chmod {}", build_script_path.display()) + })?; + } + ui::success(format!( + "Converted STARBUILD into {} and {}", + converted.output_path.display(), + build_script_path.display() + )); + } else { + ui::success(format!( + "Converted STARBUILD into {}", + converted.output_path.display() + )); + } + } Commands::Internal(args) => { let command = args.command; run_internal_command(command)?; diff --git a/src/package/interactive.rs b/src/package/interactive.rs index 010a30c..d48ae18 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -1544,6 +1544,107 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { root.insert("dependencies".into(), Value::Table(dep_tbl)); } + if !spec.package_dependencies.is_empty() { + let mut outputs_tbl = Table::new(); + for (pkg_name, deps) in &spec.package_dependencies { + let mut dep_tbl = Table::new(); + if !deps.build.is_empty() { + dep_tbl.insert( + "build".into(), + Value::Array( + deps.build + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !deps.runtime.is_empty() { + dep_tbl.insert( + "runtime".into(), + Value::Array( + deps.runtime + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !deps.test.is_empty() { + dep_tbl.insert( + "test".into(), + Value::Array(deps.test.iter().map(|s| Value::String(s.clone())).collect()), + ); + } + if !deps.optional.is_empty() { + dep_tbl.insert( + "optional".into(), + Value::Array( + deps.optional + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !dep_tbl.is_empty() { + outputs_tbl.insert(pkg_name.clone(), Value::Table(dep_tbl)); + } + } + if !outputs_tbl.is_empty() { + root.insert("package_dependencies".into(), Value::Table(outputs_tbl)); + } + } + + if !spec.package_alternatives.is_empty() { + let mut outputs_tbl = Table::new(); + for (pkg_name, alternatives) in &spec.package_alternatives { + let mut alt_tbl = Table::new(); + if !alternatives.provides.is_empty() { + alt_tbl.insert( + "provides".into(), + Value::Array( + alternatives + .provides + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !alternatives.conflicts.is_empty() { + alt_tbl.insert( + "conflicts".into(), + Value::Array( + alternatives + .conflicts + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !alternatives.replaces.is_empty() { + alt_tbl.insert( + "replaces".into(), + Value::Array( + alternatives + .replaces + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + } + if !alt_tbl.is_empty() { + outputs_tbl.insert(pkg_name.clone(), Value::Table(alt_tbl)); + } + } + if !outputs_tbl.is_empty() { + root.insert("package_alternatives".into(), Value::Table(outputs_tbl)); + } + } + Ok(toml::to_string_pretty(&Value::Table(root))?) } diff --git a/src/package/mod.rs b/src/package/mod.rs index b107c76..8a5be81 100755 --- a/src/package/mod.rs +++ b/src/package/mod.rs @@ -3,7 +3,9 @@ mod interactive; mod packager; mod spec; +mod starbuild; pub use interactive::*; pub use packager::Packager; pub use spec::*; +pub(crate) use starbuild::*; diff --git a/src/package/starbuild.rs b/src/package/starbuild.rs new file mode 100644 index 0000000..7119602 --- /dev/null +++ b/src/package/starbuild.rs @@ -0,0 +1,1368 @@ +//! Legacy STARBUILD conversion support. + +use super::{ + Alternatives, Build, BuildFlags, BuildType, Dependencies, ManualSource, PackageInfo, + PackageSpec, Source, +}; +use anyhow::{Context, Result, bail}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub(crate) struct ConvertedStarbuild { + pub output_path: PathBuf, + pub toml: String, + pub build_script: Option, + pub build_script_path: Option, +} + +#[derive(Debug, Clone, Default)] +struct ParsedStarbuild { + package_names: Vec, + package_descriptions: Vec, + package_version: String, + description: String, + licenses: Vec, + dependencies: Vec, + build_dependencies: Vec, + optional_dependencies: Vec, + provides: Vec, + conflicts: Vec, + keep: Vec, + options: Vec, + sources: Vec, + custom_assignments: Vec, + custom_vars: HashMap, + custom_functions: Vec, + prepare: Vec, + compile: Vec, + verify: Vec, + generic_assemble: Vec, + assemble_functions: BTreeMap>, + output_dependencies: BTreeMap>, + output_optional: BTreeMap>, + output_provides: BTreeMap>, + output_conflicts: BTreeMap>, + output_keep: BTreeMap>, + output_licenses: BTreeMap>, + symlinks: Vec<(String, String)>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PhaseKind { + Prepare, + Compile, + Verify, + AssembleGeneric, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum FunctionCapture { + Phase(PhaseKind), + AssembleSpecific(String), + Custom, +} + +pub(crate) fn convert_starbuild_file( + input_path: &Path, + output_override: Option<&Path>, +) -> Result { + let input_path = input_path + .canonicalize() + .with_context(|| format!("Failed to resolve STARBUILD path: {}", input_path.display()))?; + let input_text = fs::read_to_string(&input_path) + .with_context(|| format!("Failed to read STARBUILD: {}", input_path.display()))?; + + let parsed = ParsedStarbuild::parse(&input_text) + .with_context(|| format!("Failed to parse STARBUILD: {}", input_path.display()))?; + let output_path = output_override.map(PathBuf::from).unwrap_or_else(|| { + input_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(format!("{}.toml", parsed.main_package_name())) + }); + let spec_dir = output_path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let spec = parsed.to_package_spec(spec_dir)?; + let toml = super::interactive::spec_to_minimal_toml(&spec)?; + + let build_script = (!matches!(spec.build.build_type, BuildType::Meta)) + .then(|| parsed.generate_build_script()) + .transpose()?; + let build_script_path = build_script.as_ref().map(|_| { + output_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("build.sh") + }); + + Ok(ConvertedStarbuild { + output_path, + toml, + build_script, + build_script_path, + }) +} + +impl ParsedStarbuild { + fn parse(input: &str) -> Result { + let mut parsed = Self::default(); + let lines: Vec<&str> = input.lines().collect(); + let mut idx = 0usize; + let mut capture: Option = None; + let mut current_lines: Vec = Vec::new(); + + while idx < lines.len() { + let line = lines[idx]; + let trimmed = line.trim(); + + if let Some(kind) = capture.clone() { + match kind { + FunctionCapture::Custom => { + current_lines.push(line.to_string()); + if trimmed == "}" { + parsed.custom_functions.push(current_lines.join("\n")); + current_lines.clear(); + capture = None; + } + } + FunctionCapture::Phase(phase) => { + if trimmed == "}" { + parsed.push_phase_lines(phase, std::mem::take(&mut current_lines)); + capture = None; + } else { + current_lines.push(line.to_string()); + } + } + FunctionCapture::AssembleSpecific(pkg_name) => { + if trimmed == "}" { + parsed + .assemble_functions + .insert(pkg_name, std::mem::take(&mut current_lines)); + capture = None; + } else { + current_lines.push(line.to_string()); + } + } + } + idx += 1; + continue; + } + + if trimmed.is_empty() || trimmed.starts_with('#') { + idx += 1; + continue; + } + + if let Some(function_name) = parse_function_name(trimmed) { + capture = Some(match function_name.as_str() { + "prepare" => FunctionCapture::Phase(PhaseKind::Prepare), + "compile" => FunctionCapture::Phase(PhaseKind::Compile), + "verify" => FunctionCapture::Phase(PhaseKind::Verify), + "assemble" => FunctionCapture::Phase(PhaseKind::AssembleGeneric), + _ => { + if let Some(pkg_name) = function_name.strip_prefix("assemble_") { + FunctionCapture::AssembleSpecific(pkg_name.to_string()) + } else { + current_lines.push(line.to_string()); + FunctionCapture::Custom + } + } + }); + idx += 1; + continue; + } + + if let Some((key, value)) = parse_simple_assignment(trimmed) { + if key == "package_name" { + parsed.package_names = parse_array_or_string(&value); + idx += 1; + continue; + } + if key == "package_version" { + parsed.package_version = strip_matching_quotes(&value).to_string(); + idx += 1; + continue; + } + if key == "description" { + parsed.description = strip_matching_quotes(&value).to_string(); + idx += 1; + continue; + } + if key == "package_descriptions" { + parsed.package_descriptions = parse_array_or_string(&value); + idx += 1; + continue; + } + if key == "license" { + parsed.licenses = parse_array_or_string(&value); + idx += 1; + continue; + } + if key == "dependencies" { + parsed.dependencies = parse_array_or_string(&value); + idx += 1; + continue; + } + if key == "build_dependencies" { + parsed.build_dependencies = parse_array_or_string(&value); + idx += 1; + continue; + } + if matches!( + key.as_str(), + "optional_dependencies" | "optional_depedencies" | "optional_dependecies" + ) { + parsed.optional_dependencies = parse_array_or_string(&value); + idx += 1; + continue; + } + if key == "gives" { + parsed.provides = parse_array_or_string(&value); + idx += 1; + continue; + } + if matches!(key.as_str(), "clashes" | "clasheses" | "conflicts") { + parsed.conflicts = parse_array_or_string(&value); + idx += 1; + continue; + } + if key == "keep" { + parsed.keep = parse_array_or_string(&value); + idx += 1; + continue; + } + if key == "options" { + parsed.options = parse_array_or_string(&value); + idx += 1; + continue; + } + if key == "sources" || key == "source" { + parsed.sources = parse_array_or_string(&value); + idx += 1; + continue; + } + + if let Some(pkg_name) = key.strip_prefix("dependencies_") { + parsed + .output_dependencies + .insert(pkg_name.to_string(), parse_array_or_string(&value)); + idx += 1; + continue; + } + if let Some(pkg_name) = key.strip_prefix("optional_") { + parsed + .output_optional + .insert(pkg_name.to_string(), parse_array_or_string(&value)); + idx += 1; + continue; + } + if let Some(pkg_name) = key.strip_prefix("gives_") { + parsed + .output_provides + .insert(pkg_name.to_string(), parse_array_or_string(&value)); + idx += 1; + continue; + } + if let Some(pkg_name) = key.strip_prefix("clashes_") { + parsed + .output_conflicts + .insert(pkg_name.to_string(), parse_array_or_string(&value)); + idx += 1; + continue; + } + if let Some(pkg_name) = key.strip_prefix("conflicts_") { + parsed + .output_conflicts + .insert(pkg_name.to_string(), parse_array_or_string(&value)); + idx += 1; + continue; + } + if let Some(pkg_name) = key.strip_prefix("keep_") { + parsed + .output_keep + .insert(pkg_name.to_string(), parse_array_or_string(&value)); + idx += 1; + continue; + } + if let Some(pkg_name) = key.strip_prefix("license_") { + parsed + .output_licenses + .insert(pkg_name.to_string(), parse_array_or_string(&value)); + idx += 1; + continue; + } + + parsed.custom_assignments.push(trimmed.to_string()); + parsed + .custom_vars + .insert(key, strip_matching_quotes(&value).to_string()); + idx += 1; + continue; + } + + if let Some(symlink) = trimmed.strip_prefix("symlink:") { + let pair = strip_matching_quotes(symlink.trim()); + if let Some((link, target)) = pair.split_once(':') { + parsed + .symlinks + .push((link.trim().to_string(), target.trim().to_string())); + } + } + + idx += 1; + } + + if parsed.package_names.is_empty() { + bail!("STARBUILD does not define package_name"); + } + if parsed.package_version.trim().is_empty() { + bail!("STARBUILD does not define package_version"); + } + + parsed.finalize_metadata(); + Ok(parsed) + } + + fn main_package_name(&self) -> &str { + &self.package_names[0] + } + + fn finalize_metadata(&mut self) { + let main_pkg = self.main_package_name().to_string(); + self.description = expand_legacy_vars( + &self.description, + &main_pkg, + &self.package_version, + &self.custom_vars, + ); + + finalize_vec( + &mut self.package_names, + &main_pkg, + &self.package_version, + &self.custom_vars, + ); + finalize_vec( + &mut self.package_descriptions, + &main_pkg, + &self.package_version, + &self.custom_vars, + ); + finalize_vec( + &mut self.licenses, + &main_pkg, + &self.package_version, + &self.custom_vars, + ); + finalize_vec( + &mut self.dependencies, + &main_pkg, + &self.package_version, + &self.custom_vars, + ); + finalize_vec( + &mut self.build_dependencies, + &main_pkg, + &self.package_version, + &self.custom_vars, + ); + finalize_vec( + &mut self.optional_dependencies, + &main_pkg, + &self.package_version, + &self.custom_vars, + ); + finalize_vec( + &mut self.provides, + &main_pkg, + &self.package_version, + &self.custom_vars, + ); + finalize_vec( + &mut self.conflicts, + &main_pkg, + &self.package_version, + &self.custom_vars, + ); + finalize_vec( + &mut self.keep, + &main_pkg, + &self.package_version, + &self.custom_vars, + ); + finalize_vec( + &mut self.options, + &main_pkg, + &self.package_version, + &self.custom_vars, + ); + finalize_vec( + &mut self.sources, + &main_pkg, + &self.package_version, + &self.custom_vars, + ); + + finalize_map( + &mut self.output_dependencies, + &self.package_version, + &self.custom_vars, + ); + finalize_map( + &mut self.output_optional, + &self.package_version, + &self.custom_vars, + ); + finalize_map( + &mut self.output_provides, + &self.package_version, + &self.custom_vars, + ); + finalize_map( + &mut self.output_conflicts, + &self.package_version, + &self.custom_vars, + ); + finalize_map( + &mut self.output_keep, + &self.package_version, + &self.custom_vars, + ); + finalize_map( + &mut self.output_licenses, + &self.package_version, + &self.custom_vars, + ); + + for (link, target) in &mut self.symlinks { + *link = expand_legacy_vars(link, &main_pkg, &self.package_version, &self.custom_vars); + *target = + expand_legacy_vars(target, &main_pkg, &self.package_version, &self.custom_vars); + } + } + + fn to_package_spec(&self, spec_dir: PathBuf) -> Result { + let homepage = infer_homepage(&self.sources).unwrap_or_default(); + let primary_description = self + .package_descriptions + .first() + .cloned() + .unwrap_or_else(|| { + if self.description.is_empty() { + self.main_package_name().to_string() + } else { + self.description.clone() + } + }); + let default_license = if self.licenses.is_empty() { + vec!["Unknown".to_string()] + } else { + self.licenses.clone() + }; + + let mut primary = PackageInfo { + name: self.main_package_name().to_string(), + real_name: None, + version: self.package_version.clone(), + revision: 1, + description: primary_description, + homepage: homepage.clone(), + abi_breaking: false, + license: self + .output_licenses + .get(self.main_package_name()) + .cloned() + .unwrap_or_else(|| default_license.clone()), + }; + if primary.license.is_empty() { + primary.license = default_license.clone(); + } + + let multiple_outputs = self.package_names.len() > 1; + let mut extra_packages = Vec::new(); + for (idx, pkg_name) in self.package_names.iter().enumerate().skip(1) { + let mut pkg = PackageInfo { + name: pkg_name.clone(), + real_name: None, + version: self.package_version.clone(), + revision: 1, + description: self + .package_descriptions + .get(idx) + .cloned() + .unwrap_or_else(|| primary.description.clone()), + homepage: homepage.clone(), + abi_breaking: false, + license: self + .output_licenses + .get(pkg_name) + .cloned() + .unwrap_or_else(|| default_license.clone()), + }; + if pkg.license.is_empty() { + pkg.license = default_license.clone(); + } + extra_packages.push(pkg); + } + + let mut dependencies = Dependencies { + build: dedupe_preserve_order(self.build_dependencies.clone()), + runtime: dedupe_preserve_order(self.dependencies.clone()), + test: Vec::new(), + optional: dedupe_preserve_order(self.optional_dependencies.clone()), + lib32: None, + }; + let mut alternatives = Alternatives { + provides: dedupe_preserve_order(self.provides.clone()), + conflicts: dedupe_preserve_order(self.conflicts.clone()), + replaces: Vec::new(), + lib32: None, + }; + let mut package_dependencies = BTreeMap::new(); + let mut package_alternatives = BTreeMap::new(); + + if multiple_outputs { + for pkg_name in self.package_names.iter().skip(1) { + package_dependencies.insert( + pkg_name.clone(), + Dependencies { + build: Vec::new(), + runtime: dedupe_preserve_order( + self.output_dependencies + .get(pkg_name) + .cloned() + .unwrap_or_default(), + ), + test: Vec::new(), + optional: dedupe_preserve_order( + self.output_optional + .get(pkg_name) + .cloned() + .unwrap_or_default(), + ), + lib32: None, + }, + ); + package_alternatives.insert( + pkg_name.clone(), + Alternatives { + provides: dedupe_preserve_order( + self.output_provides + .get(pkg_name) + .cloned() + .unwrap_or_default(), + ), + conflicts: dedupe_preserve_order( + self.output_conflicts + .get(pkg_name) + .cloned() + .unwrap_or_default(), + ), + replaces: Vec::new(), + lib32: None, + }, + ); + } + } + + let mut keep = self.keep.clone(); + for values in self.output_keep.values() { + keep.extend(values.clone()); + } + keep = dedupe_preserve_order(keep); + + let extractable_sources = self.extractable_sources()?; + let manual_sources = self.manual_sources(); + let has_build_steps = self.has_build_steps(); + let is_meta = + extractable_sources.is_empty() && manual_sources.is_empty() && !has_build_steps; + + if multiple_outputs { + dependencies.runtime = dedupe_preserve_order(self.dependencies.clone()); + dependencies.optional = dedupe_preserve_order(self.optional_dependencies.clone()); + alternatives.provides = dedupe_preserve_order(self.provides.clone()); + alternatives.conflicts = dedupe_preserve_order(self.conflicts.clone()); + } + + Ok(PackageSpec { + package: primary, + packages: extra_packages, + alternatives, + manual_sources, + source: extractable_sources, + build: Build { + build_type: if is_meta { + BuildType::Meta + } else { + BuildType::Custom + }, + flags: BuildFlags { + keep, + use_lto: !self.option_enabled("!lto"), + no_strip: self.option_enabled("no-strip") + || self.option_enabled("nostrip") + || self.option_enabled("no-strip-binaries"), + no_delete_static: self.option_enabled("no-remove-a"), + no_compress_man: self.option_enabled("no-compress-man"), + split_docs: self.option_enabled("docs"), + ..BuildFlags::default() + }, + }, + dependencies, + package_alternatives, + package_dependencies, + spec_dir, + }) + } + + fn generate_build_script(&self) -> Result { + let primary_name = self.main_package_name(); + let install_handlers = self.install_handler_map()?; + let mut script = String::new(); + script.push_str("#!/bin/sh\nset -eu\n\n"); + script.push_str("makei() {\n"); + script.push_str(" echo \"==> Running: make DESTDIR=\\\"$pkgdir\\\" install $*\"\n"); + script.push_str(" make DESTDIR=\"$pkgdir\" install \"$@\"\n"); + script.push_str("}\n\n"); + script.push_str("mesoni() {\n"); + script.push_str(" echo \"==> Running: meson install --destdir \\\"$pkgdir\\\" $*\"\n"); + script.push_str(" meson install --destdir \"$pkgdir\" \"$@\"\n"); + script.push_str("}\n\n"); + script.push_str("cmakei() {\n"); + script.push_str(" echo \"==> Running: cmake --install $*\"\n"); + script.push_str(" DESTDIR=\"$pkgdir\" cmake --install \"$@\"\n"); + script.push_str("}\n\n"); + script.push_str("starmove() {\n"); + script.push_str(" [ \"$#\" -ge 1 ] || {\n"); + script.push_str(" echo \"starmove: requires at least one path pattern\" >&2\n"); + script.push_str(" return 1\n"); + script.push_str(" }\n"); + script.push_str(" [ \"${DEPOT_OUTPUT_NAME:-}\" != \"\" ] || {\n"); + script.push_str(" echo \"starmove: DEPOT_OUTPUT_NAME is not set\" >&2\n"); + script.push_str(" return 1\n"); + script.push_str(" }\n"); + script.push_str(" haul \"$DEPOT_OUTPUT_NAME\" \"$@\"\n"); + script.push_str("}\n\n"); + script.push_str("depot_starbuild_sync_srcdir() {\n"); + script.push_str(" workdir=${DEPOT_STARBUILD_WORKDIR:?}\n"); + script.push_str(" compat_root=\"$workdir/.depot-starbuild\"\n"); + script.push_str(" mkdir -p \"$compat_root/packages\"\n"); + script + .push_str(" for entry in \"$workdir\"/* \"$workdir\"/.[!.]* \"$workdir\"/..?*; do\n"); + script.push_str(" [ -e \"$entry\" ] || [ -L \"$entry\" ] || continue\n"); + script.push_str(" base=$(basename \"$entry\")\n"); + script.push_str(" [ \"$base\" = \".depot-starbuild\" ] && continue\n"); + script.push_str(" [ \"$base\" = \"packages\" ] && continue\n"); + script.push_str(" ln -snf \"$entry\" \"$compat_root/$base\"\n"); + script.push_str(" done\n"); + for pkg_name in &self.package_names { + let dest_expr = if pkg_name == primary_name { + "\"${DEPOT_PRIMARY_DESTDIR:-$DESTDIR}\"".to_string() + } else { + format!("\"$(subdestdir '{}')\"", sh_single_quote(pkg_name)) + }; + script.push_str(&format!( + " mkdir -p \"$compat_root/packages/{}/\"\n", + pkg_name + )); + script.push_str(&format!( + " ln -snf {} \"$compat_root/packages/{}/files\"\n", + dest_expr, pkg_name + )); + } + script.push_str(" srcdir=\"$compat_root\"\n"); + script.push_str(" export srcdir\n"); + script.push_str("}\n\n"); + script.push_str("depot_starbuild_setup_env() {\n"); + script.push_str(" package_name=$1\n"); + script.push_str(&format!( + " package_version='{}'\n", + sh_single_quote(&self.package_version) + )); + script.push_str(" pkgdir=${DESTDIR:?}\n"); + script.push_str(" export package_name package_version pkgdir\n"); + script.push_str(" LANG=C\n"); + script.push_str(" LC_ALL=C\n"); + script.push_str(" export LANG LC_ALL\n"); + script.push_str(" depot_starbuild_sync_srcdir\n"); + for assignment in &self.custom_assignments { + script.push_str(" "); + script.push_str(assignment.trim()); + script.push('\n'); + } + script.push_str("}\n\n"); + + for custom_function in &self.custom_functions { + script.push_str(custom_function); + script.push_str("\n\n"); + } + + if self.has_build_steps() { + script.push_str("depot_build() {\n"); + script.push_str(&format!( + " depot_starbuild_setup_env '{}'\n", + sh_single_quote(primary_name) + )); + append_shell_body( + &mut script, + &self.prepare, + &self.compile, + &self.verify, + None, + ); + script.push_str("}\n\n"); + } + + let primary_install = install_handlers + .get(primary_name) + .cloned() + .unwrap_or_default(); + script.push_str("depot_install() {\n"); + script.push_str(&format!( + " depot_starbuild_setup_env '{}'\n", + sh_single_quote(primary_name) + )); + append_shell_body(&mut script, &[], &[], &[], Some(&primary_install)); + for (link, target) in &self.symlinks { + script.push_str(&format!( + " mkdir -p \"$(dirname \"$pkgdir/{0}\")\"\n ln -snf '{1}' \"$pkgdir/{0}\"\n", + link, + sh_single_quote(target) + )); + } + script.push_str("}\n\n"); + + for pkg_name in self.package_names.iter().skip(1) { + let fn_suffix = shell_fn_suffix(pkg_name); + let body = install_handlers.get(pkg_name).cloned().unwrap_or_default(); + script.push_str(&format!("depot_install_{fn_suffix}() {{\n")); + script.push_str(&format!( + " depot_starbuild_setup_env '{}'\n", + sh_single_quote(pkg_name) + )); + append_shell_body(&mut script, &[], &[], &[], Some(&body)); + script.push_str("}\n\n"); + } + + Ok(script) + } + + fn has_build_steps(&self) -> bool { + !self.prepare.is_empty() || !self.compile.is_empty() || !self.verify.is_empty() + } + + fn option_enabled(&self, option: &str) -> bool { + self.options.iter().any(|value| value == option) + } + + fn extractable_sources(&self) -> Result> { + let mut out = Vec::new(); + for raw in &self.sources { + if let Some(source) = convert_extractable_source(raw, self.main_package_name())? { + out.push(source); + } + } + Ok(out) + } + + fn manual_sources(&self) -> Vec { + let mut out = Vec::new(); + for raw in &self.sources { + if let Some(manual) = convert_manual_source(raw) { + out.push(manual); + } + } + out + } + + fn install_handler_map(&self) -> Result>> { + let mut handlers = BTreeMap::new(); + for pkg_name in &self.package_names { + if let Some(body) = self.assemble_functions.get(pkg_name) { + handlers.insert(pkg_name.clone(), body.clone()); + continue; + } + if !self.generic_assemble.is_empty() { + handlers.insert(pkg_name.clone(), self.generic_assemble.clone()); + continue; + } + if pkg_name == self.main_package_name() { + handlers.insert(pkg_name.clone(), Vec::new()); + continue; + } + bail!( + "STARBUILD defines output '{}' but has no matching assemble() or assemble_{}()", + pkg_name, + pkg_name + ); + } + Ok(handlers) + } + + fn push_phase_lines(&mut self, phase: PhaseKind, lines: Vec) { + match phase { + PhaseKind::Prepare => self.prepare = lines, + PhaseKind::Compile => self.compile = lines, + PhaseKind::Verify => self.verify = lines, + PhaseKind::AssembleGeneric => self.generic_assemble = lines, + } + } +} + +fn append_shell_body( + out: &mut String, + prepare: &[String], + compile: &[String], + verify: &[String], + install: Option<&[String]>, +) { + let mut wrote = false; + for line in prepare { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + wrote = true; + } + for line in compile { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + wrote = true; + } + for line in verify { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + wrote = true; + } + if let Some(install_lines) = install { + for line in install_lines { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + wrote = true; + } + } + if !wrote { + out.push_str(" :\n"); + } +} + +fn finalize_vec( + values: &mut Vec, + pkg_name: &str, + package_version: &str, + custom_vars: &HashMap, +) { + for value in values { + *value = expand_legacy_vars(value, pkg_name, package_version, custom_vars); + } +} + +fn finalize_map( + values: &mut BTreeMap>, + package_version: &str, + custom_vars: &HashMap, +) { + for (pkg_name, items) in values { + finalize_vec(items, pkg_name, package_version, custom_vars); + } +} + +fn convert_extractable_source(raw: &str, pkg_name: &str) -> Result> { + if is_local_source(raw) || raw.starts_with("ne+") { + return Ok(None); + } + if !is_extractable_remote_source(raw) { + return Ok(None); + } + + let normalized = normalize_source_url(raw); + Ok(Some(Source { + url: normalized.clone(), + sha256: "skip".to_string(), + extract_dir: derive_extract_dir(&normalized, pkg_name), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + })) +} + +fn convert_manual_source(raw: &str) -> Option { + if is_local_source(raw) { + return Some(ManualSource { + file: Some(raw.to_string()), + files: Vec::new(), + url: None, + urls: Vec::new(), + sha256: None, + dest: None, + }); + } + + if raw.starts_with("ne+") || !is_extractable_remote_source(raw) { + return Some(ManualSource { + file: None, + files: Vec::new(), + url: Some(normalize_source_url(raw)), + urls: Vec::new(), + sha256: None, + dest: None, + }); + } + + None +} + +fn parse_function_name(line: &str) -> Option { + let open = line.find('(')?; + let close = line[open..].find(')')? + open; + let brace = line[close..].find('{')? + close; + let name = line[..open].trim(); + if brace <= close || name.is_empty() { + return None; + } + if !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') + { + return None; + } + Some(name.to_string()) +} + +fn parse_simple_assignment(line: &str) -> Option<(String, String)> { + let eq = line.find('=')?; + let key = line[..eq].trim(); + if key.is_empty() { + return None; + } + if !key + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') + { + return None; + } + Some((key.to_string(), line[eq + 1..].trim().to_string())) +} + +fn parse_array_or_string(value: &str) -> Vec { + let trimmed = value.trim(); + if let Some(inner) = trimmed.strip_prefix('(').and_then(|v| v.strip_suffix(')')) { + return tokenize_shell_words(inner); + } + if let Some(inner) = trimmed.strip_prefix('{').and_then(|v| v.strip_suffix('}')) { + return tokenize_shell_words(inner); + } + let unquoted = strip_matching_quotes(trimmed); + if unquoted.is_empty() { + Vec::new() + } else { + vec![unquoted.to_string()] + } +} + +fn tokenize_shell_words(input: &str) -> Vec { + let mut out = Vec::new(); + let mut current = String::new(); + let mut quote: Option = None; + + for ch in input.chars() { + if let Some(active_quote) = quote { + if ch == active_quote { + quote = None; + } else { + current.push(ch); + } + continue; + } + + match ch { + '"' | '\'' => quote = Some(ch), + c if c.is_whitespace() => { + if !current.is_empty() { + out.push(std::mem::take(&mut current)); + } + } + _ => current.push(ch), + } + } + + if !current.is_empty() { + out.push(current); + } + + out +} + +fn strip_matching_quotes(value: &str) -> &str { + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + &value[1..value.len() - 1] + } else { + value + } +} + +fn expand_legacy_vars( + input: &str, + pkg_name: &str, + package_version: &str, + custom_vars: &HashMap, +) -> String { + let mut current = input.to_string(); + for _ in 0..16 { + let next = expand_legacy_vars_once(¤t, pkg_name, package_version, custom_vars); + if next == current { + break; + } + current = next; + } + current +} + +fn expand_legacy_vars_once( + input: &str, + pkg_name: &str, + package_version: &str, + custom_vars: &HashMap, +) -> String { + let chars: Vec = input.chars().collect(); + let mut idx = 0usize; + let mut out = String::new(); + while idx < chars.len() { + if chars[idx] != '$' { + out.push(chars[idx]); + idx += 1; + continue; + } + + if idx + 1 < chars.len() && chars[idx + 1] == '{' { + let mut end = idx + 2; + while end < chars.len() && chars[end] != '}' { + end += 1; + } + if end >= chars.len() { + out.push(chars[idx]); + idx += 1; + continue; + } + let key: String = chars[idx + 2..end].iter().collect(); + out.push_str(&legacy_var_value( + &key, + pkg_name, + package_version, + custom_vars, + )); + idx = end + 1; + continue; + } + + let mut end = idx + 1; + while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') { + end += 1; + } + if end == idx + 1 { + out.push(chars[idx]); + idx += 1; + continue; + } + + let key: String = chars[idx + 1..end].iter().collect(); + out.push_str(&legacy_var_value( + &key, + pkg_name, + package_version, + custom_vars, + )); + idx = end; + } + out +} + +fn legacy_var_value( + key: &str, + pkg_name: &str, + package_version: &str, + custom_vars: &HashMap, +) -> String { + match key { + "package_name" => pkg_name.to_string(), + "package_version" => package_version.to_string(), + _ => custom_vars + .get(key) + .cloned() + .unwrap_or_else(|| format!("${key}")), + } +} + +fn infer_homepage(sources: &[String]) -> Option { + for source in sources { + if is_local_source(source) { + continue; + } + let normalized = normalize_source_url(source); + let base = normalized + .strip_prefix("hg+") + .unwrap_or(&normalized) + .split('#') + .next() + .unwrap_or(&normalized); + if let Some(with_scheme) = base + .strip_prefix("http://") + .map(|rest| format!("http://{}", rest)) + .or_else(|| { + base.strip_prefix("https://") + .map(|rest| format!("https://{}", rest)) + }) + .or_else(|| { + base.strip_prefix("ftp://") + .map(|rest| format!("ftp://{}", rest)) + }) + { + return Some(with_scheme); + } + } + None +} + +fn normalize_source_url(raw: &str) -> String { + raw.strip_prefix("git+") + .or_else(|| raw.strip_prefix("ne+")) + .map(str::to_string) + .unwrap_or_else(|| raw.to_string()) +} + +fn derive_extract_dir(url: &str, fallback_pkg_name: &str) -> String { + let base = url.split('#').next().unwrap_or(url); + let filename = base.rsplit('/').next().unwrap_or(base); + let without_query = filename.split('?').next().unwrap_or(filename); + + if url.starts_with("hg+") { + return without_query + .trim_end_matches(".hg") + .trim_end_matches(".git") + .to_string(); + } + + if base.ends_with(".git") || (url.contains('#') && !looks_like_archive(base)) { + return without_query.trim_end_matches(".git").to_string(); + } + + strip_archive_suffixes(without_query) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback_pkg_name.to_string()) +} + +fn strip_archive_suffixes(filename: &str) -> Option { + const SUFFIXES: &[&str] = &[ + ".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".tar.lz", ".tar.lz4", ".tgz", ".tbz2", + ".txz", ".zip", ".tar", ".gz", ".xz", ".bz2", ".zst", ".deb", ".rpm", + ]; + for suffix in SUFFIXES { + if let Some(stripped) = filename.strip_suffix(suffix) { + return Some(stripped.to_string()); + } + } + None +} + +fn looks_like_archive(url: &str) -> bool { + strip_archive_suffixes(url.rsplit('/').next().unwrap_or(url)).is_some() +} + +fn is_local_source(raw: &str) -> bool { + !raw.contains("://") + && !raw.starts_with("git+") + && !raw.starts_with("hg+") + && !raw.starts_with("ne+") +} + +fn is_extractable_remote_source(raw: &str) -> bool { + if raw.starts_with("ne+") { + return false; + } + if raw.starts_with("git+") || raw.starts_with("hg+") { + return true; + } + let normalized = normalize_source_url(raw); + let base = normalized.split('#').next().unwrap_or(&normalized); + base.ends_with(".git") || normalized.contains('#') || looks_like_archive(base) +} + +fn dedupe_preserve_order(values: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for value in values { + if seen.insert(value.clone()) { + out.push(value); + } + } + out +} + +fn shell_fn_suffix(pkg_name: &str) -> String { + let mut out = String::with_capacity(pkg_name.len().max(1)); + for ch in pkg_name.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { + out.push(ch); + } else { + out.push('_'); + } + } + if out.is_empty() { + out.push('_'); + } + if out.as_bytes().first().is_some_and(|b| b.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +fn sh_single_quote(value: &str) -> String { + value.replace('\'', "'\"'\"'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn convert_single_package_starbuild_generates_custom_spec_and_build_script() -> Result<()> { + let temp = tempfile::tempdir()?; + let starbuild = temp.path().join("STARBUILD"); + fs::write( + &starbuild, + r#" +package_name="meson" +package_version="1.2.3" +description="build system" +license=( "Apache-2.0" ) +dependencies=( "python" "ninja" ) +build_dependencies=( "git" ) +sources=( "helper.sh" "https://github.com/mesonbuild/meson.git#v$package_version" "ne+https://example.com/extra.tar.xz" ) +BUILD_ZLIB=True + +compile() { + cd meson + python -m build +} + +assemble() { + cd meson + python -m installer --destdir="$pkgdir" dist/*.whl +} +"#, + )?; + + let converted = convert_starbuild_file(&starbuild, None)?; + let spec_path = temp.path().join("meson.toml"); + assert_eq!(converted.output_path, spec_path); + assert!(converted.build_script.is_some()); + assert_eq!( + converted.build_script_path, + Some(temp.path().join("build.sh")) + ); + assert!(converted.toml.contains("type = \"custom\"")); + assert!( + converted + .toml + .contains("url = \"https://github.com/mesonbuild/meson.git#v1.2.3\"") + ); + assert!(converted.toml.contains("file = \"helper.sh\"")); + assert!( + converted + .toml + .contains("url = \"https://example.com/extra.tar.xz\"") + ); + + fs::write(&spec_path, &converted.toml)?; + fs::write( + temp.path().join("build.sh"), + converted.build_script.unwrap(), + )?; + let spec = PackageSpec::from_file(&spec_path)?; + assert_eq!(spec.package.name, "meson"); + assert_eq!(spec.dependencies.runtime, vec!["python", "ninja"]); + assert_eq!(spec.dependencies.build, vec!["git"]); + assert_eq!(spec.manual_sources.len(), 2); + assert_eq!(spec.source.len(), 1); + + Ok(()) + } + + #[test] + fn convert_multioutput_starbuild_maps_output_metadata() -> Result<()> { + let temp = tempfile::tempdir()?; + let starbuild = temp.path().join("STARBUILD"); + fs::write( + &starbuild, + r#" +package_name=( "mesa" "vulkan-intel" ) +package_version="25.3.1" +package_descriptions=( "Mesa" "Intel Vulkan" ) +license=( "MIT" ) +dependencies=( "expat" ) +build_dependencies=( "meson" ) +sources=( "https://mesa.freedesktop.org/archive/mesa-$package_version.tar.xz" ) +dependencies_vulkan-intel=( "vulkan-icd-loader" "mesa" ) +gives_vulkan-intel=( "vulkan-driver" ) +clashes_vulkan-intel=( "old-vulkan-intel" ) +keep_vulkan-intel=( "etc/vulkan/intel.conf" ) + +compile() { + cd mesa-$package_version + meson setup build +} + +assemble_mesa() { + cd mesa-$package_version + mesoni -C build +} + +assemble_vulkan-intel() { + starmove usr/lib/libvulkan_intel.so +} +"#, + )?; + + let converted = convert_starbuild_file(&starbuild, None)?; + assert!(converted.toml.contains("name = \"vulkan-intel\"")); + assert!( + converted + .toml + .contains("keep = [\"etc/vulkan/intel.conf\"]") + ); + let build_script = converted.build_script.as_deref().unwrap_or(""); + assert!(build_script.contains("depot_install_vulkan_intel()")); + assert!(build_script.contains("haul \"$DEPOT_OUTPUT_NAME\" \"$@\"")); + assert!(build_script.contains("packages/vulkan-intel/files")); + + let spec_path = temp.path().join("mesa.toml"); + fs::write(&spec_path, &converted.toml)?; + fs::write(temp.path().join("build.sh"), build_script)?; + let spec = PackageSpec::from_file(&spec_path)?; + assert_eq!(spec.packages.len(), 1); + assert_eq!(spec.packages[0].name, "vulkan-intel"); + assert_eq!( + spec.package_dependencies["vulkan-intel"].runtime, + vec!["vulkan-icd-loader", "mesa"] + ); + assert_eq!( + spec.package_alternatives["vulkan-intel"].provides, + vec!["vulkan-driver"] + ); + assert_eq!( + spec.package_alternatives["vulkan-intel"].conflicts, + vec!["old-vulkan-intel"] + ); + + Ok(()) + } +} diff --git a/src/source/mod.rs b/src/source/mod.rs index 617f04e..ebba80f 100755 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -13,6 +13,7 @@ use sha2::{Digest, Sha256}; use std::fs; use std::os::unix::fs as unix_fs; use std::path::{Path, PathBuf}; +use std::process::Command; use url::Url; use walkdir::WalkDir; @@ -418,6 +419,20 @@ fn prepare_one( // Treat `#` and bare `*.git` sources as git before local file:// // handling so `file://...repo.git#tag` resolves through the git checkout path. + if let Some((base, rev)) = split_hg_url(&url) { + let checkout_dir = build_dir.join(&extract_dir_name); + if checkout_dir.exists() && checkout_dir.join(".depot_state").exists() { + crate::log_info!( + "Resuming build in existing hg directory: {}", + checkout_dir.display() + ); + return Ok(checkout_dir); + } + checkout_hg(&base, &rev, &checkout_dir)?; + hooks::post_extract(spec, source, &checkout_dir, cache_dir)?; + return Ok(checkout_dir); + } + if let Some((base, rev)) = split_git_url(&url) { let checkout_dir = build_dir.join(&extract_dir_name); if checkout_dir.exists() && checkout_dir.join(".depot_state").exists() { @@ -556,6 +571,45 @@ fn split_git_url(url: &str) -> Option<(String, String)> { None } +fn split_hg_url(url: &str) -> Option<(String, String)> { + let rest = url.strip_prefix("hg+")?; + if let Some((base, rev)) = rest.split_once('#') { + let revision = if rev.trim().is_empty() { + "tip" + } else { + rev.trim() + }; + return Some((base.to_string(), revision.to_string())); + } + Some((rest.to_string(), "tip".to_string())) +} + +fn checkout_hg(url: &str, rev: &str, checkout_dir: &Path) -> Result<()> { + if checkout_dir.exists() { + fs::remove_dir_all(checkout_dir).with_context(|| { + format!( + "Failed to remove existing Mercurial checkout dir: {}", + checkout_dir.display() + ) + })?; + } + + crate::log_info!("Cloning Mercurial source {} @ {}...", url, rev); + let mut cmd = Command::new("hg"); + cmd.arg("clone") + .arg("-u") + .arg(rev) + .arg(url) + .arg(checkout_dir); + cmd.env("PATH", crate::runtime_env::safe_script_path()); + let status = crate::interrupts::command_status(&mut cmd) + .with_context(|| format!("Failed to run hg clone for {}", url))?; + if !status.success() { + bail!("Mercurial clone failed for {} @ {}", url, rev); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -755,6 +809,17 @@ mod tests { assert_eq!(rev, "0123456789abcdef"); } + #[test] + fn split_hg_url_accepts_revision_and_default_tip() { + let (base, rev) = split_hg_url("hg+https://hg.example.test/repo#v1").unwrap(); + assert_eq!(base, "https://hg.example.test/repo"); + assert_eq!(rev, "v1"); + + let (base, rev) = split_hg_url("hg+https://hg.example.test/repo").unwrap(); + assert_eq!(base, "https://hg.example.test/repo"); + assert_eq!(rev, "tip"); + } + #[test] fn prepare_one_rejects_cherry_pick_for_non_git_sources() { let tmp = tempfile::tempdir().unwrap();