#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
use anyhow::{Result, ensure};
use clap::builder::styling;
use clap::{ColorChoice, Parser as ClapParser, ValueEnum};
use crates_io_api::SyncClient;
use env_logger::Env;
use redskull_lib::conda;
use redskull_lib::crate_inspector::{
CargoMetadata, detect_license_files, resolve_workspace_members,
};
use redskull_lib::github_graphql;
use redskull_lib::recipe_builder::RecipeBuilder;
use redskull_lib::renderer::{MetaYamlRenderer, Renderer};
use redskull_lib::runtime_deps;
use redskull_lib::source::{self, GitHubRepo};
use redskull_lib::sys_deps;
use reqwest::blocking::ClientBuilder as ReqwestClientBuilder;
use std::path::PathBuf;
use std::process::ExitCode;
pub mod built_info {
use std::sync::LazyLock;
include!(concat!(env!("OUT_DIR"), "/built.rs"));
fn get_software_version() -> String {
let prefix = if let Some(s) = GIT_COMMIT_HASH {
format!("{}-{}", PKG_VERSION, s[0..8].to_owned())
} else {
PKG_VERSION.to_string()
};
let suffix = match GIT_DIRTY {
Some(true) => "-dirty",
_ => "",
};
format!("{prefix}{suffix}")
}
pub static VERSION: LazyLock<String> = LazyLock::new(get_software_version);
}
const STYLES: styling::Styles = styling::Styles::styled()
.header(styling::AnsiColor::Yellow.on_default().bold())
.usage(styling::AnsiColor::Yellow.on_default().bold())
.literal(styling::AnsiColor::Blue.on_default().bold())
.placeholder(styling::AnsiColor::Cyan.on_default());
#[derive(ClapParser, Debug, Clone)]
#[clap(
name = "redskull",
color = ColorChoice::Auto,
styles = STYLES,
version = built_info::VERSION.as_str())
]
#[allow(clippy::struct_excessive_bools)]
struct Opts {
#[clap(long)]
maintainers: Vec<String>,
#[clap(long)]
output: Option<PathBuf>,
#[clap(long, short = 'r', default_value = "false")]
recursive: bool,
#[clap(long = "tag")]
github_release_tag: Option<String>,
#[clap(long)]
crate_version: Option<String>,
#[clap(long, default_value = "false")]
bioconda: bool,
#[clap(long)]
cargo_bundle_licenses: Option<bool>,
#[clap(long)]
host_dep: Vec<String>,
#[clap(long)]
run_dep: Vec<String>,
#[clap(long)]
test_command: Vec<String>,
#[clap(long)]
skip_platform: Vec<String>,
#[clap(long)]
recipe_name: Option<String>,
#[clap(long, default_value = "github")]
source: SourceType,
#[clap(long)]
max_pin: Option<String>,
#[clap(long, default_value = "false")]
refs_tags: bool,
#[clap(long, default_value = "false")]
cargo_net_git_fetch: bool,
#[clap(long, default_value = "false")]
test_version: bool,
#[clap(long, default_value = "false")]
strip: bool,
#[clap(long)]
license_family: Option<bool>,
#[clap(long)]
identifier: Vec<String>,
#[clap(long)]
workspace_path: Option<String>,
args: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum SourceType {
Github,
#[value(name = "crates-io")]
CratesIo,
}
fn main() -> ExitCode {
if let Some(redskull_output) = redskull(&setup()) {
ExitCode::from(redskull_output)
} else {
ExitCode::SUCCESS
}
}
fn redskull(opts: &Opts) -> Option<u8> {
let outer = std::panic::catch_unwind(|| redskull_from_opts(opts));
match outer {
Err(_) => {
eprintln!("Error: redskull panicked. Please report this as a bug!");
Some(101)
}
Ok(inner) => match inner {
Ok(()) => None,
Err(e) => {
eprintln!("Error: {e}");
Some(2)
}
},
}
}
fn verify_host_deps(
conda_client: &reqwest::blocking::Client,
channel: &str,
detected_deps: &[(&str, Option<&str>)],
cli_deps: &[String],
) {
for (dep, _selector) in detected_deps {
match conda::is_pkg_available(conda_client, dep, channel) {
Ok(true) => log::debug!("Host dep '{dep}' found on {channel}"),
Ok(false) => log::warn!("Host dep '{dep}' not found on {channel}"),
Err(e) => log::debug!("Could not check '{dep}' on {channel}: {e}"),
}
}
for dep in cli_deps {
match conda::is_pkg_available(conda_client, dep, channel) {
Ok(true) => log::debug!("Host dep '{dep}' found on {channel}"),
Ok(false) => log::warn!("Host dep '{dep}' (from --host-dep) not found on {channel}"),
Err(e) => log::debug!("Could not check '{dep}' on {channel}: {e}"),
}
}
}
fn set_crates_io_source(
builder: &mut RecipeBuilder,
http_client: &reqwest::blocking::Client,
dl_path: &str,
checksum: &str,
) {
if source::is_valid_sha256(checksum) {
builder.crates_io_source(dl_path, checksum);
} else {
log::warn!(
"Invalid SHA256 from crates.io for {dl_path} (got '{checksum}'). Recomputing..."
);
let url = format!("https://crates.io{dl_path}");
match source::compute_sha256(http_client, &url) {
Ok((_bytes, hash)) => {
builder.crates_io_source(dl_path, &hash);
}
Err(e) => {
log::warn!("Failed to recompute SHA256: {e}. Using original checksum.");
builder.crates_io_source(dl_path, checksum);
}
}
}
}
#[allow(clippy::too_many_lines)]
fn redskull_from_opts(opts: &Opts) -> Result<()> {
ensure!(!opts.args.is_empty(), "No packages given. Please specify at least one crate.");
ensure!(!opts.recursive, "Recursive dependency resolution is not yet implemented.");
let user_agent =
format!("redskull/{} (https://github.com/fg-labs/redskull)", built_info::VERSION.as_str());
let timeout = std::time::Duration::from_secs(30);
let connect_timeout = std::time::Duration::from_secs(10);
let crates_client = SyncClient::new(&user_agent, std::time::Duration::from_millis(1000))?;
let mut http_headers = reqwest::header::HeaderMap::new();
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
http_headers.insert(
"Authorization",
format!("Bearer {token}").parse().expect("invalid GITHUB_TOKEN"),
);
log::info!("Using GitHub authentication (GITHUB_TOKEN set)");
} else {
log::debug!("No GITHUB_TOKEN set; using unauthenticated GitHub API (60 req/hr limit)");
}
let http_client = ReqwestClientBuilder::new()
.user_agent(&user_agent)
.default_headers(http_headers)
.timeout(timeout)
.connect_timeout(connect_timeout)
.build()?;
let conda_client = ReqwestClientBuilder::new()
.user_agent(&user_agent)
.timeout(timeout)
.connect_timeout(connect_timeout)
.build()?;
let conda_channel = "conda-forge";
for crate_name in &opts.args {
log::info!("Processing crate: {crate_name}");
if let Ok(repo) = GitHubRepo::from_url(crate_name) {
process_github_only(
&http_client,
&conda_client,
conda_channel,
&repo,
opts.github_release_tag.as_deref(),
opts.crate_version.as_deref(),
opts.recipe_name.as_deref(),
opts,
)?;
continue;
}
let crate_data = crates_client.full_crate(crate_name, true)?;
let mut version_str =
opts.crate_version.clone().unwrap_or_else(|| crate_data.max_version.clone());
let mut version_idx: Option<usize> = None;
let dep_list = loop {
match crates_client.crate_dependencies(&crate_data.id, &version_str) {
Ok(deps) => break deps,
Err(e) => {
log::warn!(
"Could not fetch deps for {} v{}: {e}. Trying next version.",
crate_data.id,
version_str
);
let next_idx = version_idx.map_or(0, |i| i + 1);
if next_idx >= crate_data.versions.len() {
return Err(anyhow::anyhow!(
"No valid version found for {}",
crate_data.id
));
}
version_idx = Some(next_idx);
version_str = crate_data.versions[next_idx].num.clone();
}
}
};
let version = &crate_data.versions[version_idx.unwrap_or(0)];
log::info!("Resolved {} v{}", crate_data.id, version_str);
let dep_names: Vec<&str> = dep_list.iter().map(|d| d.crate_id.as_str()).collect();
let host_deps = sys_deps::detect_host_deps(&dep_names);
let has_c = sys_deps::needs_c_compiler(&dep_names);
let has_cxx = sys_deps::needs_cxx_compiler(&dep_names);
let has_bindgen = sys_deps::needs_bindgen(&dep_names);
let pkg_config = sys_deps::needs_pkg_config(&dep_names);
let make = sys_deps::needs_make(&dep_names);
let cmake = sys_deps::needs_cmake(&dep_names);
verify_host_deps(&conda_client, conda_channel, &host_deps, &opts.host_dep);
let recipe_name = opts.recipe_name.as_deref().unwrap_or(&crate_data.id);
let mut builder = RecipeBuilder::new(recipe_name, &version_str);
let mut github_info: Option<(GitHubRepo, String)> = None;
if opts.source == SourceType::CratesIo {
set_crates_io_source(&mut builder, &http_client, &version.dl_path, &version.checksum);
} else if let Some(ref repo_url) = crate_data.repository {
if let Ok(repo) = GitHubRepo::from_url(repo_url) {
let tag_override = opts.github_release_tag.as_deref();
match source::resolve_github_source(
&http_client,
&repo,
&version_str,
tag_override,
opts.refs_tags,
) {
Ok(resolved) => {
builder.github_source_resolved(&resolved.url_template, &resolved.sha256);
github_info = Some((repo, resolved.tag));
}
Err(e) => {
log::warn!(
"Could not resolve GitHub archive for {}: {e}. \
Falling back to crates.io.",
crate_data.id
);
set_crates_io_source(
&mut builder,
&http_client,
&version.dl_path,
&version.checksum,
);
}
}
} else {
set_crates_io_source(
&mut builder,
&http_client,
&version.dl_path,
&version.checksum,
);
}
} else {
set_crates_io_source(&mut builder, &http_client, &version.dl_path, &version.checksum);
}
if let Some(ref license) = crate_data.license {
builder.license(license);
}
if let Some(ref summary) = crate_data.description {
builder.summary(summary);
}
if let Some(ref homepage) = crate_data.homepage {
builder.homepage(homepage);
}
if let Some(ref repo) = crate_data.repository {
builder.repository(repo);
}
if let Some(ref docs) = crate_data.documentation {
builder.documentation(docs);
} else if let Some((ref repo, ref tag)) = github_info {
let doc_url =
format!("https://github.com/{}/{}/blob/{tag}/README.md", repo.owner, repo.name);
builder.documentation(&doc_url);
}
if let Some((ref repo, ref tag)) = github_info {
match source::fetch_github_raw(&http_client, repo, tag, "Cargo.toml") {
Ok(cargo_toml) => match CargoMetadata::from_toml_str(&cargo_toml) {
Ok(root_meta) => {
if root_meta.is_workspace() && !root_meta.has_package() {
let raw_members = root_meta.workspace_members();
let has_globs = raw_members.iter().any(|m| m.contains('*'));
let members = if has_globs {
let tree = source::fetch_github_tree(&http_client, repo, tag)
.unwrap_or_default();
resolve_workspace_members(&raw_members, &tree)
} else {
raw_members
};
let mut all_members: Vec<(String, Vec<String>)> = Vec::new();
let mut found: Option<(String, Vec<String>)> = None;
for member in &members {
let path = format!("{member}/Cargo.toml");
let Some(toml) =
source::fetch_github_raw(&http_client, repo, tag, &path).ok()
else {
continue;
};
let Some(meta) = CargoMetadata::from_toml_str(&toml).ok() else {
continue;
};
if meta.package_name().as_deref() == Some(&*crate_data.id) {
found = Some((member.clone(), meta.binary_names()));
break;
}
all_members.push((member.clone(), meta.binary_names()));
}
if found.is_none() {
found = all_members
.into_iter()
.find(|(_, bins)| bins.iter().any(|b| b == &crate_data.id));
}
if let Some((member_path, bins)) = found {
builder.workspace_path(&member_path);
for bin in &bins {
builder.add_binary(bin);
}
} else {
log::warn!(
"Workspace has no member matching '{}'. \
You may need to set workspace_path manually.",
crate_data.id
);
builder.add_binary(&crate_data.id);
}
} else {
for bin in &root_meta.binary_names() {
builder.add_binary(bin);
}
}
}
Err(e) => {
log::warn!("Failed to parse Cargo.toml: {e}");
builder.add_binary(&crate_data.id);
}
},
Err(e) => {
log::warn!("Failed to fetch Cargo.toml from GitHub: {e}");
builder.add_binary(&crate_data.id);
}
}
} else {
builder.add_binary(&crate_data.id);
}
if let Some((ref repo, ref tag)) = github_info {
match source::fetch_github_tree(&http_client, repo, tag) {
Ok(files) => {
let hints = runtime_deps::detect_runtime_hints(&files);
for hint in &hints {
log::warn!(
"Potential run dependency: {} ({}). Consider adding: --host-dep {}",
hint.package,
hint.reason,
hint.package,
);
}
let license_files = detect_license_files(&files);
if !license_files.is_empty() {
builder.license_files(license_files);
}
}
Err(e) => {
log::debug!("Could not fetch repo tree for runtime dep detection: {e}");
}
}
}
let authors: crates_io_api::Authors =
crates_client.crate_authors(crate_name, &version_str)?;
let names = if authors.names.is_empty() {
let owners = crates_client.crate_owners(crate_name)?;
owners.into_iter().map(|user| user.login).collect()
} else {
authors.names
};
for name in names.into_iter().filter(|n| !n.starts_with("github:")) {
builder.add_maintainer(&name);
}
let has_host_deps = !host_deps.is_empty() || !opts.host_dep.is_empty();
let use_cbl = opts.cargo_bundle_licenses.unwrap_or(opts.bioconda);
builder
.bioconda(opts.bioconda)
.cargo_bundle_licenses(use_cbl)
.has_c_deps(has_c)
.has_cxx_deps(has_cxx)
.has_native_deps(has_host_deps)
.needs_bindgen(has_bindgen)
.needs_pkg_config(pkg_config)
.needs_make(make)
.needs_cmake(cmake)
.cargo_net_git_fetch(opts.cargo_net_git_fetch)
.strip_binaries(opts.strip)
.use_version_test(opts.test_version);
if let Some(ref pin) = opts.max_pin {
builder.max_pin(pin);
}
if let Some(emit) = opts.license_family {
builder.emit_license_family(emit);
}
if let Some(ref ws_path) = opts.workspace_path {
builder.workspace_path(ws_path);
}
for (dep, selector) in &host_deps {
builder.add_host_dep(dep, *selector);
}
for dep in &opts.host_dep {
builder.add_host_dep(dep, None);
}
for dep in &opts.run_dep {
builder.add_run_dep(dep, None);
}
for platform in &opts.skip_platform {
builder.skip_platform(platform);
}
for cmd in &opts.test_command {
builder.add_test_command(cmd);
}
for id in &opts.identifier {
builder.add_identifier(id);
}
let (recipe, script) = builder.build();
output_recipe(&recipe, &script, opts.output.as_deref())?;
}
Ok(())
}
fn output_recipe(
recipe: &redskull_lib::recipe::Recipe,
script: &redskull_lib::build_script::BuildScript,
output_dir: Option<&std::path::Path>,
) -> Result<()> {
let renderer = MetaYamlRenderer;
let meta_yaml = renderer.render(recipe);
if let Some(output_dir) = output_dir {
std::fs::create_dir_all(output_dir)?;
let meta_path = output_dir.join("meta.yaml");
std::fs::write(&meta_path, &meta_yaml)?;
log::info!("Wrote {}", meta_path.display());
if script.needs_build_sh() {
let build_path = output_dir.join("build.sh");
std::fs::write(&build_path, script.to_build_sh())?;
log::info!("Wrote {}", build_path.display());
}
} else {
print!("{meta_yaml}");
if script.needs_build_sh() {
println!("---");
println!("# build.sh");
print!("{}", script.to_build_sh());
}
}
Ok(())
}
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
fn process_github_only(
http_client: &reqwest::blocking::Client,
conda_client: &reqwest::blocking::Client,
conda_channel: &str,
repo: &GitHubRepo,
tag_override: Option<&str>,
version_override: Option<&str>,
recipe_name_override: Option<&str>,
opts: &Opts,
) -> Result<()> {
let tag = match tag_override {
Some(t) => t.to_string(),
None => {
log::info!("Detecting latest version via GraphQL...");
let pre_discovery = github_graphql::discover_repo(http_client, repo, "HEAD").ok();
if let Some(ref disc) = pre_discovery {
if let Some(best) = github_graphql::best_version_tag(disc) {
best
} else {
log::debug!("GraphQL found no version-like tags; falling back to REST");
source::latest_github_release(http_client, repo)?
}
} else {
log::debug!("GraphQL unavailable; falling back to REST");
source::latest_github_release(http_client, repo)?
}
}
};
let version_str =
version_override.map(String::from).unwrap_or_else(|| source::tag_to_version(&tag));
log::info!("Using tag '{tag}' (version {version_str})");
log::info!("Fetching repo metadata via GraphQL...");
let discovery = github_graphql::discover_repo(http_client, repo, &tag);
let resolved =
source::resolve_github_source(http_client, repo, &version_str, Some(&tag), opts.refs_tags)?;
let root_toml_str = if let Ok(ref disc) = discovery {
if let Some(ref toml) = disc.root_cargo_toml {
toml.clone()
} else {
source::fetch_github_raw(http_client, repo, &tag, "Cargo.toml")?
}
} else {
log::debug!("GraphQL discovery failed; fetching Cargo.toml via REST");
source::fetch_github_raw(http_client, repo, &tag, "Cargo.toml")?
};
let root_meta = CargoMetadata::from_toml_str(&root_toml_str)?;
let (pkg_meta, workspace_path) = if root_meta.is_workspace() && !root_meta.has_package() {
let target_name = recipe_name_override.unwrap_or(&repo.name);
let raw_members = root_meta.workspace_members();
let has_globs = raw_members.iter().any(|m| m.contains('*'));
let members = if has_globs {
let tree = if let Ok(ref disc) = discovery {
disc.tree.clone()
} else {
source::fetch_github_tree(http_client, repo, &tag).unwrap_or_default()
};
resolve_workspace_members(&raw_members, &tree)
} else {
raw_members
};
let member_paths: Vec<String> = members.iter().map(|m| format!("{m}/Cargo.toml")).collect();
let fetched =
github_graphql::fetch_files(http_client, repo, &tag, &member_paths).unwrap_or_default();
let mut all_members: Vec<(String, CargoMetadata)> = Vec::new();
let mut found: Option<(CargoMetadata, Option<String>)> = None;
for (i, member) in members.iter().enumerate() {
let toml_str = fetched.iter().find(|(p, _)| *p == member_paths[i]).map(|(_, c)| c);
let Some(toml_str) = toml_str else {
let path = &member_paths[i];
if let Ok(toml) = source::fetch_github_raw(http_client, repo, &tag, path) {
if let Ok(meta) = CargoMetadata::from_toml_str(&toml) {
if meta.package_name().as_deref() == Some(target_name) {
found = Some((meta, Some(member.clone())));
break;
}
all_members.push((member.clone(), meta));
}
}
continue;
};
let Some(meta) = CargoMetadata::from_toml_str(toml_str).ok() else {
continue;
};
if meta.package_name().as_deref() == Some(target_name) {
found = Some((meta, Some(member.clone())));
break;
}
all_members.push((member.clone(), meta));
}
if found.is_none() {
found = all_members.into_iter().find_map(|(member, meta)| {
let bins = meta.binary_names();
if bins.iter().any(|b| b == target_name) {
log::info!(
"Matched workspace member '{member}' by binary name '{target_name}' \
(package: {:?})",
meta.package_name()
);
Some((meta, Some(member)))
} else {
None
}
});
}
match found {
Some((meta, ws_path)) => (meta, ws_path),
None => {
return Err(anyhow::anyhow!(
"Workspace has no member matching '{target_name}'. \
Use --recipe-name to specify which crate to build."
));
}
}
} else {
(root_meta, None)
};
let ws_root_meta_str = if workspace_path.is_some() {
Some(CargoMetadata::from_toml_str(&root_toml_str)?)
} else {
None
};
let ws_ref = ws_root_meta_str.as_ref();
let recipe_name = recipe_name_override
.map(String::from)
.or_else(|| pkg_meta.package_name())
.unwrap_or_else(|| repo.name.clone());
let mut builder = RecipeBuilder::new(&recipe_name, &version_str);
builder.github_source_resolved(&resolved.url_template, &resolved.sha256);
if let Some(license) = pkg_meta.license(ws_ref) {
builder.license(&license);
}
if let Some(desc) = pkg_meta.description(ws_ref) {
builder.summary(&desc);
}
if let Some(homepage) = pkg_meta.homepage(ws_ref) {
builder.homepage(&homepage);
}
if let Some(repo_url) = pkg_meta.repository(ws_ref) {
builder.repository(&repo_url);
}
if let Some(doc_url) = pkg_meta.documentation(ws_ref) {
builder.documentation(&doc_url);
} else {
let doc_url =
format!("https://github.com/{}/{}/blob/{tag}/README.md", repo.owner, repo.name);
builder.documentation(&doc_url);
}
if let Some(ref ws_path) = workspace_path {
builder.workspace_path(ws_path);
}
let bins = pkg_meta.binary_names();
for bin in &bins {
builder.add_binary(bin);
}
let all_deps: Vec<String> = pkg_meta
.dependencies()
.into_iter()
.map(|(name, _)| name)
.chain(pkg_meta.build_dependencies())
.collect();
let dep_names: Vec<&str> = all_deps.iter().map(|s| s.as_str()).collect();
let host_deps = sys_deps::detect_host_deps(&dep_names);
let has_c = sys_deps::needs_c_compiler(&dep_names);
let has_cxx = sys_deps::needs_cxx_compiler(&dep_names);
let has_bindgen = sys_deps::needs_bindgen(&dep_names);
let pkg_config = sys_deps::needs_pkg_config(&dep_names);
let make = sys_deps::needs_make(&dep_names);
let cmake = sys_deps::needs_cmake(&dep_names);
verify_host_deps(conda_client, conda_channel, &host_deps, &opts.host_dep);
let files = if let Ok(ref disc) = discovery {
disc.tree.clone()
} else {
source::fetch_github_tree(http_client, repo, &tag).unwrap_or_default()
};
if !files.is_empty() {
let hints = runtime_deps::detect_runtime_hints(&files);
for hint in &hints {
log::warn!(
"Potential run dependency: {} ({}). Consider adding: --host-dep {}",
hint.package,
hint.reason,
hint.package,
);
}
let license_files = detect_license_files(&files);
if !license_files.is_empty() {
builder.license_files(license_files);
}
}
let has_host_deps = !host_deps.is_empty() || !opts.host_dep.is_empty();
let use_cbl = opts.cargo_bundle_licenses.unwrap_or(opts.bioconda);
builder
.bioconda(opts.bioconda)
.cargo_bundle_licenses(use_cbl)
.has_c_deps(has_c)
.has_cxx_deps(has_cxx)
.has_native_deps(has_host_deps)
.needs_bindgen(has_bindgen)
.needs_pkg_config(pkg_config)
.needs_make(make)
.needs_cmake(cmake)
.cargo_net_git_fetch(opts.cargo_net_git_fetch)
.strip_binaries(opts.strip)
.use_version_test(opts.test_version);
if let Some(ref pin) = opts.max_pin {
builder.max_pin(pin);
}
if let Some(emit) = opts.license_family {
builder.emit_license_family(emit);
}
if let Some(ref ws_path) = opts.workspace_path {
builder.workspace_path(ws_path);
}
for (dep, selector) in &host_deps {
builder.add_host_dep(dep, *selector);
}
for dep in &opts.host_dep {
builder.add_host_dep(dep, None);
}
for dep in &opts.run_dep {
builder.add_run_dep(dep, None);
}
for platform in &opts.skip_platform {
builder.skip_platform(platform);
}
for cmd in &opts.test_command {
builder.add_test_command(cmd);
}
for id in &opts.identifier {
builder.add_identifier(id);
}
let (recipe, script) = builder.build();
output_recipe(&recipe, &script, opts.output.as_deref())
}
fn setup() -> Opts {
if std::env::var("RUST_LOG").is_err() {
unsafe {
std::env::set_var("RUST_LOG", "info");
}
}
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
Opts::parse()
}