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)] #[derive(Args)]
pub struct TransferArgs { 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>, pub name: Option<String>,
/// Export saved profiles to a file. /// Export saved profiles to a file.

View File

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

View File

@@ -35,8 +35,8 @@ pub enum PortholeError {
#[error("only one of -i/--import, -e/--export may be given")] #[error("only one of -i/--import, -e/--export may be given")]
TransferConflictingMode, TransferConflictingMode,
#[error("a profile name only applies to -e/--export, not -i/--import")] #[error("'{0}' not found in the transfer file")]
TransferNameWithImport, TransferProfileNotFound(String),
#[error("nothing to do: {0}")] #[error("nothing to do: {0}")]
NothingToDo(String), 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 { fn positional_args(cmd: &clap::Command) -> String {
cmd.get_positionals() 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<_>>() .collect::<Vec<_>>()
.join(" ") .join(" ")
} }