use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Context;
use bv_core::manifest::{
EntrypointSpec, GpuSpec, HardwareSpec, ImageSpec, IoSpec, Manifest, Tier, ToolManifest,
};
use bv_types::Cardinality;
use inquire::{Confirm, CustomType, Select, Text};
use owo_colors::{OwoColorize, Stream};
use super::source::FetchedSource;
#[derive(serde::Deserialize, Default)]
pub struct PublishConfig {
#[serde(default)]
pub publish: PublishMeta,
}
#[derive(serde::Deserialize, Default)]
pub struct PublishMeta {
pub name: Option<String>,
pub version: Option<String>,
pub description: Option<String>,
pub homepage: Option<String>,
pub license: Option<String>,
#[serde(default)]
pub hardware: HardwareMeta,
#[serde(default)]
pub inputs: Vec<IoMeta>,
#[serde(default)]
pub outputs: Vec<IoMeta>,
#[serde(default)]
pub entrypoint: EntrypointMeta,
#[serde(default)]
pub subcommands: HashMap<String, String>,
}
#[derive(serde::Deserialize, Default)]
pub struct HardwareMeta {
pub cpu_cores: Option<u32>,
pub ram_gb: Option<f64>,
pub disk_gb: Option<f64>,
pub needs_gpu: Option<bool>,
}
#[derive(serde::Deserialize)]
pub struct IoMeta {
pub name: String,
#[serde(rename = "type")]
pub type_str: String,
#[serde(default)]
pub cardinality: String,
pub mount: Option<String>,
pub description: Option<String>,
}
#[derive(serde::Deserialize, Default)]
pub struct EntrypointMeta {
pub command: Option<String>,
pub args_template: Option<String>,
}
pub struct ScaffoldResult {
pub name: String,
pub version: String,
pub description: Option<String>,
pub homepage: Option<String>,
pub license: Option<String>,
pub cpu_cores: u32,
pub ram_gb: f64,
pub disk_gb: f64,
pub needs_gpu: bool,
pub inputs: Vec<IoSpec>,
pub outputs: Vec<IoSpec>,
pub entrypoint_command: String,
pub args_template: Option<String>,
pub subcommands: HashMap<String, Vec<String>>,
}
impl ScaffoldResult {
pub fn to_manifest_toml(&self, image_ref: &str, digest: &str) -> anyhow::Result<String> {
let m = Manifest {
tool: ToolManifest {
id: self.name.clone(),
version: self.version.clone(),
description: self.description.clone(),
homepage: self.homepage.clone(),
license: self.license.clone(),
tier: Tier::Community,
maintainers: vec![],
deprecated: false,
image: ImageSpec {
backend: "docker".to_string(),
reference: image_ref.to_string(),
digest: if digest.is_empty() {
None
} else {
Some(digest.to_string())
},
},
hardware: HardwareSpec {
gpu: self.needs_gpu.then_some(GpuSpec {
required: true,
min_vram_gb: None,
cuda_version: None,
}),
cpu_cores: Some(self.cpu_cores),
ram_gb: Some(self.ram_gb),
disk_gb: Some(self.disk_gb),
},
reference_data: Default::default(),
inputs: self.inputs.clone(),
outputs: self.outputs.clone(),
entrypoint: if self.entrypoint_command.is_empty() {
None
} else {
Some(EntrypointSpec {
command: self.entrypoint_command.clone(),
args_template: self.args_template.clone(),
env: Default::default(),
})
},
subcommands: self.subcommands.clone(),
cache_paths: vec![],
binaries: None,
smoke: None,
signatures: None,
},
};
m.to_toml_string().map_err(|e| anyhow::anyhow!("{}", e))
}
}
pub fn load_publish_config(dir: &Path) -> Option<PublishConfig> {
let path = dir.join("bv-publish.toml");
let content = std::fs::read_to_string(&path).ok()?;
match toml::from_str::<PublishConfig>(&content) {
Ok(cfg) => Some(cfg),
Err(e) => {
eprintln!(
" {} bv-publish.toml parse error: {}",
"warning:".if_supports_color(Stream::Stderr, |t| t.yellow().bold().to_string()),
e
);
None
}
}
}
pub fn from_config(
config: Option<&PublishConfig>,
fetched: &FetchedSource,
name_override: Option<&str>,
version_override: Option<&str>,
) -> anyhow::Result<ScaffoldResult> {
let meta = config.map(|c| &c.publish);
let name = name_override
.map(|s| s.to_string())
.or_else(|| meta.and_then(|m| m.name.clone()))
.unwrap_or_else(|| fetched.name_hint.clone());
let version = version_override
.map(|s| s.to_string())
.or_else(|| meta.and_then(|m| m.version.clone()))
.or_else(|| fetched.version_hint.clone())
.ok_or_else(|| {
anyhow::anyhow!(
"version is required in non-interactive mode\n \
Set it in bv-publish.toml or pass --tool-version"
)
})?;
let default_meta = PublishMeta::default();
let m = meta.unwrap_or(&default_meta);
let inputs = parse_io_specs(&m.inputs).context("inputs")?;
let outputs = parse_io_specs(&m.outputs).context("outputs")?;
let subcommands = parse_subcommand_strings(&m.subcommands)?;
let entrypoint_command = m.entrypoint.command.clone().unwrap_or_default();
if entrypoint_command.is_empty() && subcommands.is_empty() {
anyhow::bail!(
"either entrypoint.command or [publish.subcommands] must be set in bv-publish.toml \
(or supplied interactively)"
);
}
Ok(ScaffoldResult {
name,
version,
description: m.description.clone(),
homepage: m.homepage.clone(),
license: m.license.clone(),
cpu_cores: m.hardware.cpu_cores.unwrap_or(4),
ram_gb: m.hardware.ram_gb.unwrap_or(8.0),
disk_gb: m.hardware.disk_gb.unwrap_or(2.0),
needs_gpu: m.hardware.needs_gpu.unwrap_or(false),
inputs,
outputs,
entrypoint_command,
args_template: m.entrypoint.args_template.clone(),
subcommands,
})
}
fn parse_subcommand_strings(
raw: &HashMap<String, String>,
) -> anyhow::Result<HashMap<String, Vec<String>>> {
raw.iter()
.map(|(k, v)| {
let argv: Vec<String> =
shell_split(v).with_context(|| format!("subcommand '{k}': bad shell quoting"))?;
if argv.is_empty() {
anyhow::bail!("subcommand '{k}': command must not be empty");
}
Ok((k.clone(), argv))
})
.collect()
}
fn shell_split(s: &str) -> anyhow::Result<Vec<String>> {
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut chars = s.chars().peekable();
let mut in_single = false;
let mut in_double = false;
let mut has_token = false;
while let Some(c) = chars.next() {
match c {
'\'' if !in_double => {
in_single = !in_single;
has_token = true;
}
'"' if !in_single => {
in_double = !in_double;
has_token = true;
}
'\\' if !in_single => {
if let Some(next) = chars.next() {
cur.push(next);
has_token = true;
}
}
c if c.is_whitespace() && !in_single && !in_double => {
if has_token {
out.push(std::mem::take(&mut cur));
has_token = false;
}
}
c => {
cur.push(c);
has_token = true;
}
}
}
if in_single || in_double {
anyhow::bail!("unterminated quote");
}
if has_token {
out.push(cur);
}
Ok(out)
}
const SPDX_LICENSES: &[&str] = &[
"MIT",
"Apache-2.0",
"BSD-3-Clause",
"BSD-2-Clause",
"GPL-3.0-only",
"GPL-2.0-only",
"LGPL-3.0-only",
"MPL-2.0",
"AGPL-3.0-only",
"Unlicense",
"Proprietary",
"(none)",
"Custom…",
];
struct Form {
name: String,
version: String,
description: String,
homepage: String,
license: String,
cpu_cores: u32,
ram_gb: f64,
disk_gb: f64,
needs_gpu: bool,
inputs: Vec<IoSpec>,
outputs: Vec<IoSpec>,
entrypoint_command: String,
args_template: String,
subcommands: HashMap<String, Vec<String>>,
}
impl Form {
fn from_defaults(
meta: Option<&PublishMeta>,
fetched: &FetchedSource,
name_override: Option<&str>,
version_override: Option<&str>,
) -> anyhow::Result<Self> {
let name = name_override
.map(|s| s.to_string())
.or_else(|| meta.and_then(|m| m.name.clone()))
.unwrap_or_else(|| fetched.name_hint.clone());
let version = version_override
.map(|s| s.to_string())
.or_else(|| meta.and_then(|m| m.version.clone()))
.or_else(|| fetched.version_hint.clone())
.unwrap_or_else(|| "0.1.0".to_string());
let inputs = parse_io_specs(meta.map(|m| m.inputs.as_slice()).unwrap_or(&[]))?;
let outputs = parse_io_specs(meta.map(|m| m.outputs.as_slice()).unwrap_or(&[]))?;
let subcommands = meta
.map(|m| parse_subcommand_strings(&m.subcommands))
.transpose()?
.unwrap_or_default();
Ok(Self {
name,
version,
description: meta.and_then(|m| m.description.clone()).unwrap_or_default(),
homepage: meta
.and_then(|m| m.homepage.clone())
.unwrap_or_else(|| fetched.source_url.clone()),
license: meta.and_then(|m| m.license.clone()).unwrap_or_default(),
cpu_cores: meta.and_then(|m| m.hardware.cpu_cores).unwrap_or(4),
ram_gb: meta.and_then(|m| m.hardware.ram_gb).unwrap_or(8.0),
disk_gb: meta.and_then(|m| m.hardware.disk_gb).unwrap_or(2.0),
needs_gpu: meta.and_then(|m| m.hardware.needs_gpu).unwrap_or(false),
inputs,
outputs,
entrypoint_command: meta
.and_then(|m| m.entrypoint.command.clone())
.unwrap_or_default(),
args_template: meta
.and_then(|m| m.entrypoint.args_template.clone())
.unwrap_or_default(),
subcommands,
})
}
fn validate(&self) -> Result<(), &'static str> {
if self.name.is_empty() {
return Err("tool name is required");
}
if self.version.is_empty() {
return Err("version is required");
}
if self.entrypoint_command.is_empty() && self.subcommands.is_empty() {
return Err("declare either an entrypoint command or at least one subcommand");
}
Ok(())
}
fn into_result(self) -> ScaffoldResult {
ScaffoldResult {
name: self.name,
version: self.version,
description: non_empty(self.description),
homepage: non_empty(self.homepage),
license: non_empty(self.license),
cpu_cores: self.cpu_cores,
ram_gb: self.ram_gb,
disk_gb: self.disk_gb,
needs_gpu: self.needs_gpu,
inputs: self.inputs,
outputs: self.outputs,
entrypoint_command: self.entrypoint_command,
args_template: non_empty(self.args_template),
subcommands: self.subcommands,
}
}
}
pub fn interactive(
config: Option<&PublishConfig>,
fetched: &FetchedSource,
name_override: Option<&str>,
version_override: Option<&str>,
) -> anyhow::Result<ScaffoldResult> {
let meta = config.map(|c| &c.publish);
let mut form = Form::from_defaults(meta, fetched, name_override, version_override)?;
eprintln!();
loop {
let menu = build_menu(&form);
let choice = Select::new("Edit any field, then choose Confirm:", menu)
.with_page_size(20)
.prompt()?;
match choice.action {
Action::Confirm => match form.validate() {
Ok(()) => return Ok(form.into_result()),
Err(msg) => eprintln!(
" {} {msg}",
"error:".if_supports_color(Stream::Stderr, |t| t.red().bold().to_string())
),
},
Action::Cancel => anyhow::bail!("publish cancelled"),
Action::Edit(field) => edit_field(&mut form, field)?,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Field {
Name,
Version,
Description,
Homepage,
License,
CpuCores,
RamGb,
DiskGb,
NeedsGpu,
Inputs,
Outputs,
Entrypoint,
Subcommands,
}
#[derive(Debug, Clone, Copy)]
enum Action {
Confirm,
Cancel,
Edit(Field),
}
#[derive(Debug, Clone)]
struct MenuItem {
label: String,
action: Action,
}
impl std::fmt::Display for MenuItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
}
}
fn build_menu(form: &Form) -> Vec<MenuItem> {
fn row(label: &str, value: impl AsRef<str>, action: Action) -> MenuItem {
let v = value.as_ref();
let display = if v.is_empty() {
"(none)".to_string()
} else {
v.to_string()
};
MenuItem {
label: format!("{label:<14} {display}"),
action,
}
}
let inputs_summary = if form.inputs.is_empty() {
"(none)".to_string()
} else {
format!(
"{} declared: {}",
form.inputs.len(),
form.inputs
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
};
let outputs_summary = if form.outputs.is_empty() {
"(none)".to_string()
} else {
format!(
"{} declared: {}",
form.outputs.len(),
form.outputs
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
};
let entrypoint_summary = if form.entrypoint_command.is_empty() {
"(none)".to_string()
} else if form.args_template.is_empty() {
form.entrypoint_command.clone()
} else {
format!("{} {}", form.entrypoint_command, form.args_template)
};
let subs_summary = if form.subcommands.is_empty() {
"(none)".to_string()
} else {
let mut names: Vec<&str> = form.subcommands.keys().map(|s| s.as_str()).collect();
names.sort();
format!("{} declared: {}", form.subcommands.len(), names.join(", "))
};
vec![
MenuItem {
label: "Confirm and continue".into(),
action: Action::Confirm,
},
row("Tool name", &form.name, Action::Edit(Field::Name)),
row("Version", &form.version, Action::Edit(Field::Version)),
row(
"Description",
&form.description,
Action::Edit(Field::Description),
),
row("Homepage", &form.homepage, Action::Edit(Field::Homepage)),
row("License", &form.license, Action::Edit(Field::License)),
row(
"CPU cores",
form.cpu_cores.to_string(),
Action::Edit(Field::CpuCores),
),
row(
"RAM (GB)",
format!("{}", form.ram_gb),
Action::Edit(Field::RamGb),
),
row(
"Disk (GB)",
format!("{}", form.disk_gb),
Action::Edit(Field::DiskGb),
),
row(
"GPU required",
if form.needs_gpu { "yes" } else { "no" },
Action::Edit(Field::NeedsGpu),
),
row("Inputs", inputs_summary, Action::Edit(Field::Inputs)),
row("Outputs", outputs_summary, Action::Edit(Field::Outputs)),
row(
"Entrypoint",
entrypoint_summary,
Action::Edit(Field::Entrypoint),
),
row(
"Subcommands",
subs_summary,
Action::Edit(Field::Subcommands),
),
MenuItem {
label: "Cancel".into(),
action: Action::Cancel,
},
]
}
fn edit_field(form: &mut Form, field: Field) -> anyhow::Result<()> {
match field {
Field::Name => form.name = text("Tool name", &form.name, false)?,
Field::Version => form.version = text("Version", &form.version, false)?,
Field::Description => form.description = text("Description", &form.description, true)?,
Field::Homepage => form.homepage = text("Homepage URL", &form.homepage, true)?,
Field::License => form.license = pick_license(&form.license)?,
Field::CpuCores => form.cpu_cores = number("CPU cores", form.cpu_cores)?,
Field::RamGb => form.ram_gb = number("RAM (GB)", form.ram_gb)?,
Field::DiskGb => form.disk_gb = number("Disk (GB)", form.disk_gb)?,
Field::NeedsGpu => {
form.needs_gpu = Confirm::new("Needs GPU?")
.with_default(form.needs_gpu)
.prompt()?;
}
Field::Inputs => edit_io_list(&mut form.inputs, "input")?,
Field::Outputs => edit_io_list(&mut form.outputs, "output")?,
Field::Entrypoint => edit_entrypoint(form)?,
Field::Subcommands => edit_subcommands(&mut form.subcommands)?,
}
Ok(())
}
fn text(prompt: &str, current: &str, allow_empty: bool) -> anyhow::Result<String> {
let mut t = Text::new(prompt);
if !current.is_empty() {
t = t.with_initial_value(current);
}
let v = t.prompt()?;
if !allow_empty && v.is_empty() {
anyhow::bail!("{prompt} must not be empty");
}
Ok(v)
}
fn number<T>(prompt: &str, current: T) -> anyhow::Result<T>
where
T: Clone + std::fmt::Display + std::str::FromStr,
T::Err: std::fmt::Debug + std::fmt::Display,
{
let v = CustomType::<T>::new(prompt)
.with_default(current)
.with_error_message("please enter a valid number")
.prompt()?;
Ok(v)
}
fn pick_license(current: &str) -> anyhow::Result<String> {
let cursor = SPDX_LICENSES
.iter()
.position(|x| *x == current)
.unwrap_or(SPDX_LICENSES.len() - 2); let chosen = Select::new("License", SPDX_LICENSES.to_vec())
.with_starting_cursor(cursor)
.prompt()?;
Ok(match chosen {
"(none)" => String::new(),
"Custom…" => Text::new("Custom SPDX identifier (or full text)")
.with_initial_value(current)
.prompt()?,
other => other.to_string(),
})
}
fn edit_entrypoint(form: &mut Form) -> anyhow::Result<()> {
let want = Confirm::new("Declare an entrypoint command?")
.with_default(!form.entrypoint_command.is_empty())
.with_help_message(if form.subcommands.is_empty() {
"required when no subcommands are declared"
} else {
"optional — subcommands cover this tool"
})
.prompt()?;
if !want {
form.entrypoint_command.clear();
form.args_template.clear();
return Ok(());
}
let default_cmd = if form.entrypoint_command.is_empty() {
form.name.clone()
} else {
form.entrypoint_command.clone()
};
form.entrypoint_command = Text::new("Command")
.with_initial_value(&default_cmd)
.prompt()?;
let tmpl = Text::new("Args template")
.with_help_message("use {port_name}, {cpu_cores}; leave blank to skip")
.with_initial_value(&form.args_template)
.prompt()?;
form.args_template = tmpl;
Ok(())
}
fn edit_io_list(specs: &mut Vec<IoSpec>, label: &str) -> anyhow::Result<()> {
loop {
#[derive(Debug, Clone)]
struct Row {
label: String,
kind: RowKind,
}
#[derive(Debug, Clone)]
enum RowKind {
Done,
Add,
Edit(usize),
Remove(usize),
}
impl std::fmt::Display for Row {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
}
}
let mut rows: Vec<Row> = vec![Row {
label: "Done".into(),
kind: RowKind::Done,
}];
rows.push(Row {
label: format!("Add {label}"),
kind: RowKind::Add,
});
for (i, spec) in specs.iter().enumerate() {
rows.push(Row {
label: format!(
"Edit: {} [{}] ({})",
spec.name, spec.r#type, spec.cardinality
),
kind: RowKind::Edit(i),
});
rows.push(Row {
label: format!("Remove: {}", spec.name),
kind: RowKind::Remove(i),
});
}
let pick = Select::new(&format!("{label}s"), rows)
.with_page_size(20)
.prompt()?;
match pick.kind {
RowKind::Done => return Ok(()),
RowKind::Add => {
let s = prompt_io_spec(label, None)?;
specs.push(s);
}
RowKind::Edit(i) => {
let s = prompt_io_spec(label, Some(&specs[i]))?;
specs[i] = s;
}
RowKind::Remove(i) => {
specs.remove(i);
}
}
}
}
fn prompt_io_spec(label: &str, existing: Option<&IoSpec>) -> anyhow::Result<IoSpec> {
let initial_name = existing.map(|s| s.name.clone()).unwrap_or_default();
let name = Text::new(&format!("{label} name"))
.with_initial_value(&initial_name)
.prompt()?;
if name.is_empty() {
anyhow::bail!("name must not be empty");
}
let initial_type = existing.map(|s| s.r#type.to_string()).unwrap_or_default();
let type_str = prompt_type(&initial_type)?;
let cardinalities = vec!["one", "many", "optional"];
let cursor = existing
.map(|s| match s.cardinality {
Cardinality::One => 0,
Cardinality::Many => 1,
Cardinality::Optional => 2,
})
.unwrap_or(0);
let cardinality = match Select::new("Cardinality", cardinalities)
.with_starting_cursor(cursor)
.prompt()?
{
"many" => Cardinality::Many,
"optional" => Cardinality::Optional,
_ => Cardinality::One,
};
let default_mount = existing
.and_then(|s| s.mount.as_ref().map(|p| p.to_string_lossy().into_owned()))
.unwrap_or_else(|| format!("/workspace/{name}"));
let mount = Text::new("Mount path in container")
.with_initial_value(&default_mount)
.prompt()?;
let initial_desc = existing
.and_then(|s| s.description.clone())
.unwrap_or_default();
let description = Text::new("Description (optional)")
.with_initial_value(&initial_desc)
.prompt()?;
Ok(IoSpec {
name,
r#type: type_str.parse().map_err(|e| anyhow::anyhow!("{}", e))?,
cardinality,
mount: Some(PathBuf::from(mount)),
description: non_empty(description),
default: None,
})
}
fn edit_subcommands(subs: &mut HashMap<String, Vec<String>>) -> anyhow::Result<()> {
loop {
#[derive(Debug, Clone)]
struct Row {
label: String,
kind: SubKind,
}
#[derive(Debug, Clone)]
enum SubKind {
Done,
Add,
Edit(String),
Remove(String),
}
impl std::fmt::Display for Row {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
}
}
let mut rows: Vec<Row> = vec![
Row {
label: "Done".into(),
kind: SubKind::Done,
},
Row {
label: "Add subcommand".into(),
kind: SubKind::Add,
},
];
let mut names: Vec<&String> = subs.keys().collect();
names.sort();
for n in names {
let cmd = subs[n].join(" ");
rows.push(Row {
label: format!("Edit: {n} = {cmd}"),
kind: SubKind::Edit(n.clone()),
});
rows.push(Row {
label: format!("Remove: {n}"),
kind: SubKind::Remove(n.clone()),
});
}
let pick = Select::new("Subcommands", rows)
.with_page_size(20)
.with_help_message("for multi-script tools, e.g. genie2 train, genie2 sample")
.prompt()?;
match pick.kind {
SubKind::Done => return Ok(()),
SubKind::Add => {
let (n, argv) = prompt_subcommand("", &[])?;
subs.insert(n, argv);
}
SubKind::Edit(name) => {
let existing = subs.get(&name).cloned().unwrap_or_default();
let (new_name, argv) = prompt_subcommand(&name, &existing)?;
if new_name != name {
subs.remove(&name);
}
subs.insert(new_name, argv);
}
SubKind::Remove(name) => {
subs.remove(&name);
}
}
}
}
fn prompt_subcommand(
initial_name: &str,
initial_cmd: &[String],
) -> anyhow::Result<(String, Vec<String>)> {
let name = Text::new("Subcommand name")
.with_help_message("e.g. train, sample_unconditional")
.with_initial_value(initial_name)
.prompt()?;
if name.is_empty() || name.starts_with('-') {
anyhow::bail!("name must be non-empty and not start with '-'");
}
let initial_cmd_str = initial_cmd.join(" ");
let cmd_str = Text::new("Command")
.with_help_message("e.g. python genie/train.py")
.with_initial_value(&initial_cmd_str)
.prompt()?;
let argv = shell_split(&cmd_str)?;
if argv.is_empty() {
anyhow::bail!("command must not be empty");
}
Ok((name, argv))
}
fn prompt_type(initial: &str) -> anyhow::Result<String> {
loop {
let input = Text::new("Type")
.with_help_message("enter ? to list all types")
.with_initial_value(initial)
.prompt()?;
if input == "?" {
print_type_list();
continue;
}
let base = input.split('[').next().unwrap_or(&input);
if bv_types::lookup(base).is_some() {
return Ok(input);
}
if let Some(suggestion) = bv_types::suggest(base) {
eprintln!(
" {} unknown type '{}', did you mean '{}'?",
"hint:".if_supports_color(Stream::Stderr, |t| t.yellow().to_string()),
base,
suggestion
);
} else {
eprintln!(
" {} unknown type '{}'; enter ? to list all types",
"hint:".if_supports_color(Stream::Stderr, |t| t.yellow().to_string()),
base
);
}
}
}
fn print_type_list() {
let mut ids: Vec<&str> = bv_types::known_type_ids().collect();
ids.sort_unstable();
eprintln!(
"\n {}",
"Available types:".if_supports_color(Stream::Stderr, |t| t.bold().to_string())
);
for id in ids {
if let Some(def) = bv_types::lookup(id) {
eprintln!(" {:20} {}", id, def.description);
}
}
eprintln!();
}
fn parse_io_specs(metas: &[IoMeta]) -> anyhow::Result<Vec<IoSpec>> {
metas
.iter()
.map(|m| {
let type_ref = m
.type_str
.parse()
.map_err(|e| anyhow::anyhow!("invalid type '{}': {}", m.type_str, e))?;
let cardinality = match m.cardinality.as_str() {
"many" => Cardinality::Many,
"optional" => Cardinality::Optional,
_ => Cardinality::One,
};
Ok(IoSpec {
name: m.name.clone(),
r#type: type_ref,
cardinality,
mount: m.mount.as_deref().map(PathBuf::from),
description: m.description.clone(),
default: None,
})
})
.collect()
}
fn non_empty(s: String) -> Option<String> {
if s.is_empty() { None } else { Some(s) }
}