Add transfer subcommand for bulk profile export/import

-e/--export writes every saved profile to a single TOML file as a
[[profile]] array; -i/--import reads one back and saves each entry,
failing on the first name collision rather than silently overwriting.
Reuses Profile's existing Serialize/Deserialize impl directly, so the
file format is just the same shape already written to
~/.config/porthole/profiles/<name>.toml, aggregated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 22:46:57 +02:00
parent 0e15ac061b
commit 125d72c918
5 changed files with 73 additions and 0 deletions

View File

@@ -41,6 +41,9 @@ pub enum Commands {
#[command(visible_alias = "reset")]
Wipe(WipeArgs),
/// Export saved profiles to a file, or import them from one.
Transfer(TransferArgs),
/// Generate a shell completion script.
Completions { shell: Shell },
@@ -182,3 +185,14 @@ pub struct WipeArgs {
#[arg(short, long)]
pub yes: bool,
}
#[derive(Args)]
pub struct TransferArgs {
/// Export all saved profiles to a file.
#[arg(short, long, value_name = "PATH")]
pub export: Option<String>,
/// Import profiles from a file.
#[arg(short, long, value_name = "PATH")]
pub import: Option<String>,
}

View File

@@ -6,6 +6,7 @@ pub mod list;
pub mod open;
pub mod remove;
pub mod status;
pub mod transfer;
pub mod wipe;
use crate::cli::MappingArgs;

51
src/commands/transfer.rs Normal file
View File

@@ -0,0 +1,51 @@
//! Bulk profile backup/restore - a flat TOML array of the same `Profile`
//! records `profile::save`/`load` already read and write, so it round-trips
//! through the exact same serialization with nothing profile-specific here.
use crate::cli::TransferArgs;
use crate::error::{PortholeError, Result};
use crate::profile::{self, Profile};
use crate::{atomic, ui};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Default, Serialize, Deserialize)]
struct TransferFile {
#[serde(rename = "profile", default)]
profiles: Vec<Profile>,
}
pub fn run(args: TransferArgs) -> Result<()> {
match (&args.export, &args.import) {
(Some(_), Some(_)) => Err(PortholeError::TransferConflictingMode),
(None, None) => Err(PortholeError::TransferNoMode),
(Some(path), None) => export(path),
(None, Some(path)) => import(path),
}
}
fn export(path: &str) -> Result<()> {
let file = TransferFile { profiles: profile::list_all()? };
let text = toml::to_string_pretty(&file)?;
atomic::write(Path::new(path), text.as_bytes())?;
ui::ok(&format!("Exported {} profile(s) to '{path}'.", file.profiles.len()));
Ok(())
}
fn import(path: &str) -> Result<()> {
let text = std::fs::read_to_string(path)?;
let file: TransferFile = toml::from_str(&text)?;
for p in &file.profiles {
profile::require_valid_name(&p.name)?;
if profile::exists(&p.name) {
return Err(PortholeError::AlreadyExists(p.name.clone()));
}
}
for p in &file.profiles {
profile::save(p)?;
}
ui::ok(&format!("Imported {} profile(s) from '{path}'.", file.profiles.len()));
Ok(())
}

View File

@@ -29,6 +29,12 @@ pub enum PortholeError {
#[error("--via is required: at least one hop (connection target)")]
NoViaHosts,
#[error("exactly one of -i/--import, -e/--export is required")]
TransferNoMode,
#[error("only one of -i/--import, -e/--export may be given")]
TransferConflictingMode,
#[error("nothing to do: {0}")]
NothingToDo(String),

View File

@@ -37,6 +37,7 @@ fn main() {
Commands::List(args) => commands::list::run(args),
Commands::Remove(args) => commands::remove::run(args),
Commands::Wipe(args) => commands::wipe::run(args),
Commands::Transfer(args) => commands::transfer::run(args),
Commands::Completions { shell } => {
commands::completions::run(shell);
Ok(())