Show [name] for optional positionals, allow filtering import by name too

The custom --help renderer showed every positional as <name> regardless
of whether it was actually required, which was misleading for open and
transfer (both take an optional profile name). positional_args() now
checks Arg::is_required_set() and renders [name] for an optional one.

transfer's name positional previously only worked with --export and was
rejected outright when combined with --import. It now filters --import
the same way: 'porthole transfer <name> -i file.toml' imports just that
one profile out of the file instead of everything in it, erroring with
a new TransferProfileNotFound if the file doesn't contain it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 00:54:27 +02:00
parent 5ce0614cad
commit ab0fd69503
4 changed files with 28 additions and 12 deletions

View File

@@ -188,7 +188,7 @@ pub struct WipeArgs {
#[derive(Args)]
pub struct TransferArgs {
/// Name of the profile to export; every saved profile if omitted. Not used for import.
/// Restrict export/import to just this profile; every profile in scope if omitted.
pub name: Option<String>,
/// Export saved profiles to a file.

View File

@@ -19,9 +19,8 @@ pub fn run(args: TransferArgs) -> Result<()> {
match (&args.export, &args.import) {
(Some(_), Some(_)) => Err(PortholeError::TransferConflictingMode),
(None, None) => Err(PortholeError::TransferNoMode),
(None, Some(_)) if args.name.is_some() => Err(PortholeError::TransferNameWithImport),
(Some(path), None) => export(path, args.name.as_deref()),
(None, Some(path)) => import(path),
(None, Some(path)) => import(path, args.name.as_deref()),
}
}
@@ -48,22 +47,34 @@ fn export(path: &str, name: Option<&str>) -> Result<()> {
Ok(())
}
fn import(path: &str) -> Result<()> {
fn import(path: &str, name: Option<&str>) -> Result<()> {
let text = std::fs::read_to_string(path)?;
let file: TransferFile = toml::from_str(&text)?;
for p in &file.profiles {
let selected = match name {
Some(n) => {
let n = profile::normalize(n);
let found = file.profiles.into_iter().find(|p| p.name == n);
vec![found.ok_or(PortholeError::TransferProfileNotFound(n))?]
}
None => file.profiles,
};
for p in &selected {
profile::require_valid_name(&p.name)?;
if profile::exists(&p.name) {
return Err(PortholeError::AlreadyExists(p.name.clone()));
}
}
for p in &file.profiles {
for p in &selected {
profile::save(p)?;
}
ui::ok(&format!("Imported {} profile(s) from '{path}'.", file.profiles.len()));
warn_about_missing_identities(&file.profiles);
match name {
Some(n) => ui::ok(&format!("Imported profile '{n}' from '{path}'.")),
None => ui::ok(&format!("Imported {} profile(s) from '{path}'.", selected.len())),
}
warn_about_missing_identities(&selected);
Ok(())
}

View File

@@ -35,8 +35,8 @@ pub enum PortholeError {
#[error("only one of -i/--import, -e/--export may be given")]
TransferConflictingMode,
#[error("a profile name only applies to -e/--export, not -i/--import")]
TransferNameWithImport,
#[error("'{0}' not found in the transfer file")]
TransferProfileNotFound(String),
#[error("nothing to do: {0}")]
NothingToDo(String),

View File

@@ -139,10 +139,15 @@ fn print_full_help() {
}
}
/// `<name>` for each positional arg of `cmd`, space-joined and colored cyan.
/// `<name>` for each required positional arg of `cmd`, `[name]` for an
/// optional one, space-joined and colored cyan.
fn positional_args(cmd: &clap::Command) -> String {
cmd.get_positionals()
.map(|a| ui::cyan(&format!("<{}>", a.get_id().as_str())))
.map(|a| {
let id = a.get_id().as_str();
let text = if a.is_required_set() { format!("<{id}>") } else { format!("[{id}]") };
ui::cyan(&text)
})
.collect::<Vec<_>>()
.join(" ")
}