use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use crate::i18n::Strings;
use crate::postman_api::{PostmanClient, RateBucket, WorkspaceKind, WorkspaceSummary};
use crate::postman_import::{
ImportFormat, ImportMsg, ImportOptions, ImportPlan, ImportSummary, Importer, ItemKind,
WaitReason, parse_workspace_ref,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Step {
Connect,
PickWorkspace,
Options,
Confirm,
Downloading,
Done,
Failed(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Phase {
ListingWorkspaces,
Planning,
Downloading,
}
impl Phase {
pub(crate) fn label(self, s: &Strings) -> &'static str {
match self {
Phase::ListingWorkspaces => s.postman_busy_listing,
Phase::Planning => s.postman_busy_planning,
Phase::Downloading => s.postman_busy_downloading,
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Progress {
pub(crate) done: usize,
pub(crate) total: usize,
pub(crate) current: String,
pub(crate) current_kind: Option<ItemKind>,
pub(crate) waiting: Option<(WaitReason, u64)>,
started: Option<Instant>,
totals: [usize; 2],
spent: [Duration; 2],
samples: [usize; 2],
in_flight: Option<(ItemKind, Instant)>,
}
fn kind_slot(kind: ItemKind) -> usize {
match kind {
ItemKind::Collection => 0,
ItemKind::Environment => 1,
}
}
impl Progress {
fn start_item(&mut self, kind: ItemKind) {
let now = Instant::now();
if let Some((prev, at)) = self.in_flight.take() {
let slot = kind_slot(prev);
self.spent[slot] += now.saturating_duration_since(at);
self.samples[slot] += 1;
}
self.in_flight = Some((kind, now));
}
fn per_item(&self, kind: ItemKind, overall: Duration) -> Duration {
let slot = kind_slot(kind);
match self.samples[slot] {
0 => overall,
n => self.spent[slot] / n as u32,
}
}
pub(crate) fn eta(&self) -> Option<Duration> {
let started = self.started?;
if self.done == 0 || self.done >= self.total {
return None;
}
let elapsed = Instant::now().saturating_duration_since(started);
let overall = elapsed / self.done as u32;
if self.totals.iter().sum::<usize>() != self.total {
return Some(overall * (self.total - self.done) as u32);
}
let left = |kind: ItemKind| -> u32 {
let slot = kind_slot(kind);
self.totals[slot].saturating_sub(self.samples[slot]) as u32
};
Some(
self.per_item(ItemKind::Collection, overall) * left(ItemKind::Collection)
+ self.per_item(ItemKind::Environment, overall) * left(ItemKind::Environment),
)
}
pub(crate) fn fraction(&self) -> f32 {
if self.total == 0 {
return 0.0;
}
self.done as f32 / self.total as f32
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PreviewRow {
pub(crate) depth: usize,
pub(crate) label: String,
pub(crate) method: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Preview {
pub(crate) uid: String,
pub(crate) name: String,
pub(crate) rows: Vec<PreviewRow>,
pub(crate) requests: usize,
pub(crate) notes: Vec<String>,
}
impl Preview {
fn build(uid: String, name: String, body: &str) -> Self {
let converted = crate::postman::convert_postman(body);
let requests = converted.entries.len();
Self {
uid,
name,
rows: preview_rows(&converted.entries),
requests,
notes: converted
.notes
.iter()
.map(|n| {
if n.item.is_empty() {
n.detail.clone()
} else {
format!("{}: {}", n.item, n.detail)
}
})
.collect(),
}
}
}
fn preview_rows(entries: &[crate::hurl::HurlEntry]) -> Vec<PreviewRow> {
let mut rows: Vec<PreviewRow> = Vec::new();
let mut open: Vec<&str> = Vec::new();
for entry in entries {
let mut parts: Vec<&str> = entry.title.split('/').collect();
let name = parts.pop().unwrap_or_default();
let shared = open
.iter()
.zip(parts.iter())
.take_while(|(a, b)| a == b)
.count();
open.truncate(shared);
for folder in &parts[shared..] {
rows.push(PreviewRow {
depth: open.len(),
label: (*folder).to_string(),
method: None,
});
open.push(folder);
}
rows.push(PreviewRow {
depth: open.len(),
label: name.to_string(),
method: Some(entry.method.clone()),
});
}
rows
}
#[derive(Debug)]
pub(crate) enum PostmanEvent {
Imported(Box<ImportSummary>),
}
enum Msg {
Workspaces(Result<Vec<WorkspaceSummary>, String>),
Planned(Box<ImportPlan>),
Finished(Box<ImportSummary>),
Failed(String),
}
pub(crate) struct PostmanFlow {
pub(crate) key: String,
pub(crate) workspace_ref: String,
pub(crate) base_url: String,
pub(crate) dest: String,
pub(crate) include_collections: bool,
pub(crate) include_environments: bool,
pub(crate) format: ImportFormat,
pub(crate) overwrite: bool,
pub(crate) filter: String,
pub(crate) selected: usize,
step: Step,
busy: Option<Phase>,
busy_since: Option<Instant>,
budgets: Vec<Budget>,
workspaces: Vec<WorkspaceSummary>,
chosen: Vec<WorkspaceSummary>,
plan: Option<ImportPlan>,
progress: Progress,
failures: Vec<(String, String)>,
pub(crate) preview_sel: usize,
preview: Option<Preview>,
preview_cache: HashMap<String, Preview>,
preview_rx: Option<Receiver<Result<Box<Preview>, String>>>,
preview_pending: Option<String>,
preview_error: Option<String>,
peek_open: Option<String>,
peek_cache: HashMap<String, Vec<String>>,
peek_rx: Option<Receiver<Result<(String, Vec<String>), String>>>,
peek_pending: Option<String>,
peek_error: Option<String>,
rx: Option<Receiver<Msg>>,
progress_rx: Option<Receiver<ImportMsg>>,
go: Option<Sender<()>>,
cancel: Arc<AtomicBool>,
resolved_key: Arc<Mutex<Option<(String, String)>>>,
key_ready: Arc<Mutex<Option<Instant>>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum KeySource {
Paste,
#[default]
OnePassword,
Ssm,
Env,
}
impl KeySource {
pub(crate) const ALL: [KeySource; 4] = [
KeySource::OnePassword,
KeySource::Ssm,
KeySource::Env,
KeySource::Paste,
];
pub(crate) fn cycled(self, forward: bool) -> Self {
let i = Self::ALL.iter().position(|k| *k == self).unwrap_or(0);
let n = Self::ALL.len();
Self::ALL[if forward { i + 1 } else { i + n - 1 } % n]
}
pub(crate) fn is_secret(self) -> bool {
matches!(self, KeySource::Paste)
}
pub(crate) fn source_label(self, s: &Strings) -> &'static str {
match self {
KeySource::Paste => s.postman_key_source_paste,
KeySource::OnePassword => s.postman_key_source_op,
KeySource::Ssm => s.postman_key_source_ssm,
KeySource::Env => s.postman_key_source_env,
}
}
pub(crate) fn field_label(self, s: &Strings) -> &'static str {
match self {
KeySource::Paste => s.postman_key_label,
KeySource::OnePassword => s.postman_key_label_op,
KeySource::Ssm => s.postman_key_label_ssm,
KeySource::Env => s.postman_key_label_env,
}
}
pub(crate) fn field_hint(self, s: &Strings) -> &'static str {
match self {
KeySource::Paste => s.postman_key_hint,
KeySource::OnePassword => s.postman_key_hint_op,
KeySource::Ssm => s.postman_key_hint_ssm,
KeySource::Env => s.postman_key_hint_env,
}
}
pub(crate) fn field_help(self, s: &Strings) -> &'static str {
match self {
KeySource::Paste => s.postman_key_help_paste,
KeySource::OnePassword => s.postman_key_help_op,
KeySource::Ssm => s.postman_key_help_ssm,
KeySource::Env => s.postman_key_help_env,
}
}
pub(crate) fn reference(self, entry: &str) -> String {
let entry = entry.trim();
if entry.is_empty() || entry.contains("{{") {
return entry.to_string();
}
match self {
KeySource::Paste => entry.to_string(),
KeySource::OnePassword => {
let path = entry.strip_prefix("op://").unwrap_or(entry);
format!("{{{{ op://{path} }}}}")
}
KeySource::Ssm => {
let name = entry.strip_prefix("ssm:").unwrap_or(entry);
format!("{{{{ ssm:{name} }}}}")
}
KeySource::Env => {
let name = entry.strip_prefix("env:").unwrap_or(entry);
format!("{{{{ env:{name} }}}}")
}
}
}
pub(crate) fn detect(raw: &str) -> (Self, String) {
let raw = raw.trim();
if raw.is_empty() {
return (KeySource::default(), String::new());
}
let Some(inner) = raw
.strip_prefix("{{")
.and_then(|r| r.strip_suffix("}}"))
.map(str::trim)
else {
return (KeySource::Paste, raw.to_string());
};
if let Some(path) = inner.strip_prefix("op://") {
(KeySource::OnePassword, path.to_string())
} else if let Some(name) = inner.strip_prefix("ssm:") {
(KeySource::Ssm, name.to_string())
} else if let Some(name) = inner.strip_prefix("env:") {
(KeySource::Env, name.to_string())
} else {
(KeySource::Paste, raw.to_string())
}
}
}
fn resolve_key(
raw: &str,
cache: &Mutex<Option<(String, String)>>,
ready: &Mutex<Option<Instant>>,
) -> Option<String> {
let raw = raw.trim();
if !raw.contains("{{") {
return (!raw.is_empty()).then(|| raw.to_string());
}
if let Ok(guard) = cache.lock()
&& let Some((cached_raw, value)) = guard.as_ref()
&& cached_raw == raw
{
return Some(value.clone());
}
let value = crate::environment::resolve_reference(raw)?;
if let Ok(mut guard) = cache.lock() {
*guard = Some((raw.to_string(), value.clone()));
}
if let Ok(mut guard) = ready.lock() {
*guard = Some(Instant::now());
}
Some(value)
}
impl Default for PostmanFlow {
fn default() -> Self {
Self::new()
}
}
impl PostmanFlow {
pub(crate) fn new() -> Self {
Self {
key: String::new(),
workspace_ref: String::new(),
base_url: String::new(),
dest: String::new(),
include_collections: true,
include_environments: true,
format: ImportFormat::default(),
overwrite: false,
filter: String::new(),
selected: 0,
step: Step::Connect,
busy: None,
busy_since: None,
budgets: Vec::new(),
workspaces: Vec::new(),
chosen: Vec::new(),
plan: None,
progress: Progress::default(),
failures: Vec::new(),
preview_sel: 0,
preview: None,
preview_cache: HashMap::new(),
preview_rx: None,
preview_pending: None,
preview_error: None,
peek_open: None,
peek_cache: HashMap::new(),
peek_rx: None,
peek_pending: None,
peek_error: None,
rx: None,
progress_rx: None,
go: None,
cancel: Arc::new(AtomicBool::new(false)),
resolved_key: Arc::new(Mutex::new(None)),
key_ready: Arc::new(Mutex::new(None)),
}
}
pub(crate) fn with_env_key(mut self) -> Self {
if let Ok(k) = std::env::var("POSTMAN_API_KEY")
&& !k.trim().is_empty()
{
self.key = k.trim().to_string();
}
self
}
pub(crate) fn step(&self) -> &Step {
&self.step
}
fn set_busy(&mut self, phase: Phase) {
self.busy = Some(phase);
self.busy_since = Some(Instant::now());
}
fn clear_busy(&mut self) {
self.busy = None;
self.busy_since = None;
self.progress.waiting = None;
}
pub(crate) fn busy_line(&self, s: &Strings) -> Option<String> {
let phase = self.busy?;
let mut line = phase.label(s).to_string();
if let Some((reason, secs)) = self.progress.waiting {
let why = match reason {
crate::postman_import::WaitReason::Pacing => s.postman_waiting_paced,
crate::postman_import::WaitReason::RateLimited => s.postman_waiting_limited,
};
line.push_str(&format!(
" \u{2014} {why} ({})",
human_duration(Duration::from_secs(secs), s)
));
}
if let Some(started) = self.busy_since {
let elapsed = started.elapsed();
if elapsed >= Duration::from_secs(3) {
line.push_str(&format!(" \u{b7} {}", human_duration(elapsed, s)));
}
}
Some(line)
}
pub(crate) fn budget_line(&self, s: &Strings) -> Option<String> {
let now = Instant::now();
let fresh: Vec<&Budget> = self.budgets.iter().filter(|b| !b.is_stale(now)).collect();
let b = *fresh.iter().max_by_key(|b| {
(
b.interval_secs,
b.remaining.is_some(),
std::cmp::Reverse(b.remaining),
)
})?;
let mut parts: Vec<String> = Vec::new();
if let Some(n) = b.remaining {
let window = match b.reset_secs {
Some(secs) if secs > 0 => format!(
"{} {} ({})",
n,
s.postman_budget_window,
human_duration(Duration::from_secs(secs), s)
),
_ => format!("{n} {}", s.postman_budget_window),
};
parts.push(window);
}
let monthly = fresh
.iter()
.filter(|b| b.remaining_month.is_some())
.max_by_key(|b| b.seen)
.and_then(|b| b.remaining_month);
if let Some(n) = monthly {
parts.push(format!("{n} {}", s.postman_budget_month));
}
if b.interval_secs > 0 {
parts.push(format!(
"{} {}",
s.postman_budget_pace,
human_duration(Duration::from_secs(b.interval_secs), s)
));
}
if parts.is_empty() {
return None;
}
Some(format!(
"{}: {}",
s.postman_budget_label,
parts.join(" \u{b7} ")
))
}
pub(crate) fn key_to_remember(&self) -> Option<&str> {
let proven = !self.workspaces.is_empty() || !self.chosen.is_empty() || self.plan.is_some();
let key = self.key.trim();
(proven && !key.is_empty() && !KeySource::detect(key).0.is_secret()).then_some(key)
}
pub(crate) fn is_busy(&self) -> bool {
self.busy.is_some()
}
pub(crate) fn error(&self) -> Option<&str> {
match &self.step {
Step::Failed(e) => Some(e.as_str()),
_ => None,
}
}
pub(crate) fn plan(&self) -> Option<&ImportPlan> {
self.plan.as_ref()
}
pub(crate) fn progress(&self) -> &Progress {
&self.progress
}
pub(crate) fn failures(&self) -> &[(String, String)] {
&self.failures
}
pub(crate) fn preview(&self) -> Option<&Preview> {
self.preview.as_ref()
}
pub(crate) fn preview_pending(&self) -> Option<&str> {
self.preview_pending.as_deref()
}
pub(crate) fn preview_error(&self) -> Option<&str> {
self.preview_error.as_deref()
}
pub(crate) fn preview_is_cached(&self, id: &str) -> bool {
self.preview_cache.contains_key(id)
}
pub(crate) fn previewable(&self) -> &[crate::postman_api::ItemSummary] {
self.plan.as_ref().map_or(&[], |p| p.collections.as_slice())
}
pub(crate) fn move_preview_sel(&mut self, delta: isize) {
let len = self.previewable().len();
if len == 0 {
return;
}
let next = (self.preview_sel as isize + delta).clamp(0, len as isize - 1);
self.preview_sel = next as usize;
}
pub(crate) fn preview_collection(&mut self, index: usize, s: &Strings) {
if self.preview_pending.is_some() {
return;
}
let Some((id, name)) = self
.previewable()
.get(index)
.map(|item| (item.fetch_id().to_string(), item.name.clone()))
else {
return;
};
self.preview_sel = index;
self.preview_error = None;
if let Some(cached) = self.preview_cache.get(&id) {
self.preview = Some(cached.clone());
return;
}
let (tx, rx) = mpsc::channel();
let raw = self.key.trim().to_string();
let base = self.base_url_opt();
let cache = Arc::clone(&self.resolved_key);
let ready = Arc::clone(&self.key_ready);
let bad_ref = s.postman_err_key_ref;
let pending = id.clone();
thread::spawn(move || {
let key = match resolve_key(&raw, &cache, &ready) {
Some(k) => k,
None => {
let _ = tx.send(Err(bad_ref.to_string()));
return;
}
};
let client = PostmanClient::new(key, base);
let msg = match client.get_collection(&id) {
Ok((body, _rate)) => Ok(Box::new(Preview::build(id, name, &body))),
Err(e) => Err(e.to_string()),
};
let _ = tx.send(msg);
});
self.preview_rx = Some(rx);
self.preview_pending = Some(pending);
}
pub(crate) fn preview_selected(&mut self, s: &Strings) {
self.preview_collection(self.preview_sel, s);
}
pub(crate) fn close_preview(&mut self) -> bool {
self.preview_error = None;
self.preview.take().is_some()
}
fn drain_preview(&mut self) {
let Some(rx) = self.preview_rx.as_ref() else {
return;
};
match rx.try_recv() {
Ok(Ok(preview)) => {
self.preview_cache
.insert(preview.uid.clone(), (*preview).clone());
self.preview = Some(*preview);
self.preview_rx = None;
self.preview_pending = None;
}
Ok(Err(e)) => {
self.preview_error = Some(e);
self.preview_rx = None;
self.preview_pending = None;
}
Err(TryRecvError::Empty) => {}
Err(TryRecvError::Disconnected) => {
self.preview_rx = None;
self.preview_pending = None;
}
}
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub(crate) fn peek_open(&self) -> Option<&str> {
self.peek_open.as_deref()
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub(crate) fn peek_pending(&self) -> Option<&str> {
self.peek_pending.as_deref()
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub(crate) fn peek_error(&self) -> Option<&str> {
self.peek_error.as_deref()
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub(crate) fn workspace_peek(&self, id: &str) -> Option<&[String]> {
self.peek_cache.get(id).map(Vec::as_slice)
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub(crate) fn open_workspace_peek(&mut self, id: &str, s: &Strings) {
if self.peek_pending.is_some() {
return;
}
self.peek_open = Some(id.to_string());
self.peek_error = None;
if self.peek_cache.contains_key(id) {
return;
}
let (tx, rx) = mpsc::channel();
let raw = self.key.trim().to_string();
let base = self.base_url_opt();
let cache = Arc::clone(&self.resolved_key);
let ready = Arc::clone(&self.key_ready);
let bad_ref = s.postman_err_key_ref;
let wanted = id.to_string();
let id = id.to_string();
thread::spawn(move || {
let key = match resolve_key(&raw, &cache, &ready) {
Some(k) => k,
None => {
let _ = tx.send(Err(bad_ref.to_string()));
return;
}
};
let client = PostmanClient::new(key, base);
let msg = match client.list_collections(&id) {
Ok((items, _rate)) => Ok((id, items.into_iter().map(|i| i.name).collect())),
Err(e) => Err(e.to_string()),
};
let _ = tx.send(msg);
});
self.peek_rx = Some(rx);
self.peek_pending = Some(wanted);
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub(crate) fn close_workspace_peek(&mut self) -> bool {
self.peek_error = None;
self.peek_open.take().is_some()
}
fn drain_peek(&mut self) {
let Some(rx) = self.peek_rx.as_ref() else {
return;
};
match rx.try_recv() {
Ok(Ok((id, names))) => {
self.peek_cache.insert(id, names);
self.peek_rx = None;
self.peek_pending = None;
}
Ok(Err(e)) => {
self.peek_error = Some(e);
self.peek_rx = None;
self.peek_pending = None;
}
Err(TryRecvError::Empty) => {}
Err(TryRecvError::Disconnected) => {
self.peek_rx = None;
self.peek_pending = None;
}
}
}
pub(crate) fn workspaces(&self) -> &[WorkspaceSummary] {
&self.workspaces
}
pub(crate) fn visible_workspaces(&self) -> Vec<&WorkspaceSummary> {
let needle = self.filter.trim().to_lowercase();
self.workspaces
.iter()
.filter(|w| needle.is_empty() || w.name.to_lowercase().contains(&needle))
.collect()
}
pub(crate) fn selected_workspace(&self) -> Option<&WorkspaceSummary> {
self.visible_workspaces().get(self.selected).copied()
}
pub(crate) fn workspace_name(&self) -> &str {
match self.chosen.as_slice() {
[only] => only.name.as_str(),
_ => "",
}
}
pub(crate) fn target_count(&self) -> usize {
self.chosen.len()
}
pub(crate) fn target_label(&self, s: &Strings) -> String {
match self.chosen.as_slice() {
[] => String::new(),
[only] => only.name.clone(),
many => format!("{} ({})", s.postman_all_workspaces, many.len()),
}
}
pub(crate) fn dest_path(&self) -> PathBuf {
PathBuf::from(self.dest.trim())
}
fn options(&self) -> ImportOptions {
ImportOptions {
include_collections: self.include_collections,
include_environments: self.include_environments,
format: self.format,
overwrite: self.overwrite,
}
}
fn base_url_opt(&self) -> Option<String> {
let t = self.base_url.trim();
(!t.is_empty()).then(|| t.to_string())
}
pub(crate) fn submit_connect(&mut self, s: &Strings) {
if self.key.trim().is_empty() {
self.step = Step::Failed(s.postman_err_key_required.to_string());
return;
}
let typed = self.workspace_ref.trim().to_string();
if !typed.is_empty() {
let Some(id) = parse_workspace_ref(&typed) else {
self.step = Step::Failed(s.postman_err_bad_workspace.to_string());
return;
};
self.chosen = vec![WorkspaceSummary {
id,
name: typed,
kind: WorkspaceKind::Other(String::new()),
}];
self.step = Step::Options;
return;
}
self.start_listing(s);
}
fn start_listing(&mut self, s: &Strings) {
let (tx, rx) = mpsc::channel();
let raw = self.key.trim().to_string();
let base = self.base_url_opt();
let cache = Arc::clone(&self.resolved_key);
let ready = Arc::clone(&self.key_ready);
let bad_ref = s.postman_err_key_ref;
thread::spawn(move || {
let key = match resolve_key(&raw, &cache, &ready) {
Some(k) => k,
None => {
let _ = tx.send(Msg::Workspaces(Err(bad_ref.to_string())));
return;
}
};
let client = PostmanClient::new(key, base);
let kinds = WorkspaceKind::default_selection();
let msg = match client.list_workspaces(&kinds) {
Ok((mut ws, _rate)) => {
ws.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Msg::Workspaces(Ok(ws))
}
Err(e) => Msg::Workspaces(Err(e.to_string())),
};
let _ = tx.send(msg);
});
self.rx = Some(rx);
self.set_busy(Phase::ListingWorkspaces);
self.step = Step::PickWorkspace;
}
pub(crate) fn submit_workspace(&mut self) -> bool {
let Some(ws) = self.selected_workspace().cloned() else {
return false;
};
self.chosen = vec![ws];
self.step = Step::Options;
true
}
pub(crate) fn submit_all_workspaces(&mut self) -> bool {
let all: Vec<WorkspaceSummary> = self.visible_workspaces().into_iter().cloned().collect();
if all.is_empty() {
return false;
}
self.chosen = all;
self.step = Step::Options;
true
}
pub(crate) fn submit_options(&mut self, s: &Strings) -> bool {
if self.dest.trim().is_empty() {
self.step = Step::Failed(s.postman_err_dest_required.to_string());
return false;
}
if !self.include_collections && !self.include_environments {
self.step = Step::Failed(s.postman_err_nothing_selected.to_string());
return false;
}
let targets: Vec<(String, String)> = self
.chosen
.iter()
.map(|w| (w.id.clone(), w.name.clone()))
.collect();
if targets.is_empty() {
self.step = Step::Failed(s.postman_err_no_workspace.to_string());
return false;
}
let (tx, rx) = mpsc::channel();
let (progress_tx, progress_rx) = mpsc::channel();
let (go_tx, go_rx) = mpsc::channel::<()>();
let raw = self.key.trim().to_string();
let base = self.base_url_opt();
let cache = Arc::clone(&self.resolved_key);
let ready = Arc::clone(&self.key_ready);
let bad_ref = s.postman_err_key_ref;
let options = self.options();
let dest = self.dest_path();
let cancel = Arc::clone(&self.cancel);
cancel.store(false, Ordering::Relaxed);
thread::spawn(move || {
let key = match resolve_key(&raw, &cache, &ready) {
Some(k) => k,
None => {
let _ = tx.send(Msg::Failed(bad_ref.to_string()));
return;
}
};
let client = PostmanClient::new(key, base);
let mut importer = Importer::new(&client)
.with_progress(progress_tx)
.with_cancel(cancel);
let planned = match targets.as_slice() {
[(id, name)] => importer.plan(id, name, &options),
many => importer.plan_all(many, &options),
};
let plan = match planned {
Ok(p) => p,
Err(e) => {
let _ = tx.send(Msg::Failed(e.to_string()));
return;
}
};
if tx.send(Msg::Planned(Box::new(plan.clone()))).is_err() {
return; }
if go_rx.recv().is_err() {
return;
}
match importer.download(&plan, &dest, &options) {
Ok(summary) => {
let _ = tx.send(Msg::Finished(Box::new(summary)));
}
Err(e) => {
let _ = tx.send(Msg::Failed(e.to_string()));
}
}
});
self.rx = Some(rx);
self.progress_rx = Some(progress_rx);
self.go = Some(go_tx);
self.reset_preview();
self.set_busy(Phase::Planning);
self.step = Step::Confirm;
true
}
pub(crate) fn confirm(&mut self) -> bool {
let Some(plan) = self.plan.as_ref() else {
return false;
};
let total = plan.item_count();
let Some(go) = self.go.take() else {
return false;
};
if go.send(()).is_err() {
return false; }
self.progress = Progress {
total,
totals: [plan.collections.len(), plan.environments.len()],
started: Some(Instant::now()),
..Progress::default()
};
self.set_busy(Phase::Downloading);
self.step = Step::Downloading;
true
}
pub(crate) fn cancel(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
self.go = None;
}
pub(crate) fn to_pick_workspace(&mut self) {
self.chosen.clear();
self.plan = None;
self.reset_preview();
self.step = Step::PickWorkspace;
}
fn reset_preview(&mut self) {
self.preview = None;
self.preview_rx = None;
self.preview_pending = None;
self.preview_error = None;
self.preview_sel = 0;
}
fn reset_peek(&mut self) {
self.peek_open = None;
self.peek_rx = None;
self.peek_pending = None;
self.peek_error = None;
}
pub(crate) fn back_to_connect(&mut self) {
self.cancel();
self.rx = None;
self.progress_rx = None;
self.clear_busy();
self.workspaces.clear();
self.chosen.clear();
self.plan = None;
self.reset_preview();
self.reset_peek();
self.peek_cache.clear();
self.selected = 0;
self.filter.clear();
self.step = Step::Connect;
}
pub(crate) fn clear_error(&mut self, back_to: Step) {
if matches!(self.step, Step::Failed(_)) {
self.step = self.recoverable(back_to);
}
}
pub(crate) fn recoverable(&self, back_to: Step) -> Step {
match back_to {
Step::PickWorkspace if self.workspaces.is_empty() => Step::Connect,
Step::Options | Step::Confirm | Step::Downloading if self.chosen.is_empty() => {
Step::Connect
}
Step::Confirm | Step::Downloading if self.plan.is_none() => Step::Options,
Step::Downloading => Step::Options,
other => other,
}
}
pub(crate) fn fail(&mut self, message: String) {
self.clear_busy();
self.rx = None;
self.progress_rx = None;
self.go = None;
self.step = Step::Failed(message);
}
pub(crate) fn poll(&mut self, s: &Strings) -> Option<PostmanEvent> {
self.absorb_approval_wait();
self.drain_progress();
self.drain_preview();
self.drain_peek();
let result = self.rx.as_ref().map(Receiver::try_recv)?;
match result {
Ok(msg) => self.apply(msg, s),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
self.rx = None;
if self.busy.is_some() && !self.cancel.load(Ordering::Relaxed) {
self.fail(s.postman_err_worker_ended.to_string());
}
self.clear_busy();
None
}
}
}
fn absorb_approval_wait(&mut self) {
let Some(at) = self.key_ready.lock().ok().and_then(|mut g| g.take()) else {
return;
};
if self.busy_since.is_some_and(|started| started < at) {
self.busy_since = Some(at);
}
if self.progress.started.is_some_and(|started| started < at) {
self.progress.started = Some(at);
}
}
fn drain_progress(&mut self) {
let Some(rx) = self.progress_rx.as_ref() else {
return;
};
let msgs: Vec<ImportMsg> = rx.try_iter().collect();
for msg in msgs {
match msg {
ImportMsg::Item {
index,
total,
kind,
name,
} => {
self.progress.done = index.saturating_sub(1);
self.progress.total = total;
self.progress.current = name;
self.progress.current_kind = Some(kind);
self.progress.waiting = None;
self.progress.start_item(kind);
}
ImportMsg::Waiting { reason, secs } => {
self.progress.waiting = Some((reason, secs));
}
ImportMsg::Budget {
bucket,
remaining,
reset_secs,
remaining_month,
interval_secs,
} => {
let entry = Budget {
bucket,
remaining,
reset_secs,
remaining_month,
interval_secs,
seen: Instant::now(),
};
match self.budgets.iter_mut().find(|b| b.bucket == bucket) {
Some(slot) => *slot = entry,
None => self.budgets.push(entry),
}
}
ImportMsg::ItemFailed { name, error } => {
self.failures.push((name, error));
}
ImportMsg::Listing
| ImportMsg::Planned(_)
| ImportMsg::Done(_)
| ImportMsg::Failed(_) => {}
}
}
}
fn apply(&mut self, msg: Msg, s: &Strings) -> Option<PostmanEvent> {
match msg {
Msg::Workspaces(Ok(ws)) => {
self.clear_busy();
self.rx = None;
if ws.is_empty() {
self.fail(s.postman_err_no_workspaces.to_string());
return None;
}
self.workspaces = ws;
self.selected = 0;
None
}
Msg::Workspaces(Err(e)) => {
self.fail(e);
None
}
Msg::Planned(plan) => {
self.clear_busy();
if let [chosen] = self.chosen.as_mut_slice()
&& !plan.workspace_name().trim().is_empty()
{
chosen.name = plan.workspace_name().to_string();
}
self.plan = Some(*plan);
None
}
Msg::Finished(summary) => {
self.clear_busy();
self.rx = None;
self.progress_rx = None;
self.progress.done = self.progress.total;
self.progress.waiting = None;
self.failures = summary.failures.clone();
self.step = Step::Done;
Some(PostmanEvent::Imported(summary))
}
Msg::Failed(e) => {
self.fail(e);
None
}
}
}
}
#[cfg(test)]
impl PostmanFlow {
pub(crate) fn seed_step(&mut self, step: Step) {
self.step = step;
}
pub(crate) fn seed_workspaces(&mut self, workspaces: Vec<WorkspaceSummary>) {
self.workspaces = workspaces;
}
pub(crate) fn seed_chosen(&mut self, workspace: WorkspaceSummary) {
self.chosen = vec![workspace];
}
pub(crate) fn seed_plan(&mut self, plan: ImportPlan) {
self.plan = Some(plan);
}
pub(crate) fn seed_preview_cache(&mut self, preview: Preview) {
self.preview_cache.insert(preview.uid.clone(), preview);
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub(crate) fn seed_peek_cache(&mut self, workspace_id: &str, collections: Vec<String>) {
self.peek_cache
.insert(workspace_id.to_string(), collections);
}
}
pub(crate) fn default_dest_name(workspace: &str) -> String {
let cleaned: String = workspace
.trim()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
let cleaned = cleaned.trim().to_string();
if cleaned.is_empty() {
"Postman".to_string()
} else {
cleaned
}
}
pub(crate) fn plan_summary(plan: &ImportPlan, s: &Strings) -> String {
let items = format!(
"{} {} · {} {}",
plan.collections.len(),
s.postman_word_collections,
plan.environments.len(),
s.postman_word_environments
);
if plan.is_multi() {
format!(
"{} {} · {items}",
plan.workspaces.len(),
s.postman_word_workspaces
)
} else {
items
}
}
pub(crate) fn plan_skipped_line(plan: &ImportPlan, s: &Strings) -> Option<String> {
let n = plan.skipped.len();
if n == 0 {
return None;
}
let word = if n == 1 {
s.postman_word_workspace
} else {
s.postman_word_workspaces
};
Some(format!("{n} {word} {}", s.postman_ws_skipped))
}
pub(crate) fn imported_counts(collections: usize, environments: usize, s: &Strings) -> String {
let word = |n: usize, one: &'static str, many: &'static str| if n == 1 { one } else { many };
format!(
"{} {} · {} {}",
collections,
word(
collections,
s.postman_word_collection,
s.postman_word_collections
),
environments,
word(
environments,
s.postman_word_environment,
s.postman_word_environments
)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Budget {
bucket: RateBucket,
remaining: Option<u64>,
reset_secs: Option<u64>,
remaining_month: Option<u64>,
interval_secs: u64,
seen: Instant,
}
impl Budget {
fn is_stale(&self, now: Instant) -> bool {
let window = Duration::from_secs(self.reset_secs.filter(|s| *s > 0).unwrap_or(60));
now.saturating_duration_since(self.seen) > window
}
}
pub(crate) fn human_duration(d: Duration, s: &Strings) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("{} {}", secs.max(1), s.postman_unit_seconds)
} else {
let mins = secs.div_ceil(60);
format!("{mins} {}", s.postman_unit_minutes)
}
}
pub(crate) fn item_kind_label(kind: ItemKind, s: &Strings) -> &'static str {
match kind {
ItemKind::Collection => s.postman_word_collection,
ItemKind::Environment => s.postman_word_environment,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::Language;
use crate::postman_api::ItemSummary;
use crate::postman_import::WorkspacePlan;
fn s() -> Strings {
Strings::for_language(&Language::English)
}
#[test]
fn a_key_source_wraps_what_the_user_typed_and_reads_it_back() {
for src in KeySource::ALL {
let (back, entry) = KeySource::detect(&src.reference("secret/thing"));
assert_eq!(back, src, "{src:?} must survive the round trip");
assert_eq!(entry, "secret/thing");
}
assert_eq!(
KeySource::OnePassword.reference("Private/Postman/cred"),
"{{ op://Private/Postman/cred }}"
);
assert_eq!(
KeySource::Ssm.reference("/paperboy/postman"),
"{{ ssm:/paperboy/postman }}"
);
assert_eq!(
KeySource::Env.reference("POSTMAN_API_KEY"),
"{{ env:POSTMAN_API_KEY }}"
);
}
#[test]
fn a_key_source_leaves_a_user_who_already_knows_the_syntax_alone() {
assert_eq!(
KeySource::OnePassword.reference("{{ op://a/b/c }}"),
"{{ op://a/b/c }}"
);
assert_eq!(
KeySource::OnePassword.reference("op://a/b/c"),
"{{ op://a/b/c }}"
);
assert_eq!(KeySource::Paste.reference(""), "");
assert_eq!(
KeySource::detect("PMAK-abc"),
(KeySource::Paste, "PMAK-abc".to_string())
);
assert_eq!(
KeySource::detect("{{ vault:x }}"),
(KeySource::Paste, "{{ vault:x }}".to_string())
);
}
#[test]
fn the_key_sources_cycle_both_ways_without_falling_off_the_end() {
assert_eq!(KeySource::default(), KeySource::OnePassword);
assert_eq!(KeySource::OnePassword.cycled(false), KeySource::Paste);
assert_eq!(KeySource::Paste.cycled(true), KeySource::OnePassword);
}
fn flow() -> PostmanFlow {
let mut f = PostmanFlow::new();
f.key = "PMAK-test".to_string();
f.dest = "/tmp/pb-import-test".to_string();
f
}
#[test]
fn a_typed_key_is_used_as_it_stands() {
let cache = Mutex::new(None);
let ready = Mutex::new(None);
assert_eq!(
resolve_key(" PMAK-abcdef ", &cache, &ready),
Some("PMAK-abcdef".to_string())
);
assert!(
cache.lock().unwrap().is_none(),
"nothing to remember: no provider was asked"
);
}
#[test]
fn a_resolved_key_is_remembered_for_the_rest_of_the_import() {
let raw = "{{ op://Private/Postman/credential }}";
let cache = Mutex::new(Some((raw.to_string(), "PMAK-from-1password".to_string())));
let ready = Mutex::new(None);
assert_eq!(
resolve_key(raw, &cache, &ready),
Some("PMAK-from-1password".to_string())
);
}
#[test]
fn editing_the_key_does_not_reuse_the_previous_answer() {
let cache = Mutex::new(Some((
"{{ op://Private/Postman/credential }}".to_string(),
"PMAK-old".to_string(),
)));
let ready = Mutex::new(None);
assert_eq!(
resolve_key("PMAK-typed-instead", &cache, &ready),
Some("PMAK-typed-instead".to_string())
);
}
fn ws(name: &str, id: &str) -> WorkspaceSummary {
WorkspaceSummary {
id: id.to_string(),
name: name.to_string(),
kind: WorkspaceKind::Team,
}
}
fn plan_with(collections: usize, environments: usize) -> ImportPlan {
ImportPlan::new(
vec![WorkspacePlan {
workspace_id: "ws".into(),
workspace_name: "Billing".into(),
collections: (0..collections)
.map(|i| ItemSummary {
uid: format!("u{i}"),
id: format!("i{i}"),
name: format!("c{i}"),
})
.collect(),
environments: (0..environments)
.map(|i| ItemSummary {
uid: format!("e{i}"),
id: format!("e{i}"),
name: format!("e{i}"),
})
.collect(),
}],
Vec::new(),
None,
)
}
#[test]
fn connecting_without_a_key_is_refused_before_any_request() {
let mut f = PostmanFlow::new();
f.submit_connect(&s());
assert_eq!(f.error(), Some(s().postman_err_key_required));
assert!(f.rx.is_none(), "no worker was started");
}
#[test]
fn a_supplied_workspace_id_skips_the_listing_step() {
let mut f = flow();
f.workspace_ref =
"https://go.postman.co/workspace/Team~11111111-2222-3333-4444-555555555555".to_string();
f.submit_connect(&s());
assert_eq!(*f.step(), Step::Options);
assert!(!f.is_busy(), "nothing was fetched");
assert_eq!(
f.chosen[0].id, "11111111-2222-3333-4444-555555555555",
"the id was taken out of the pasted address"
);
}
#[test]
fn an_unrecognisable_workspace_reference_is_reported() {
let mut f = flow();
f.workspace_ref = "the billing one".to_string();
f.submit_connect(&s());
assert_eq!(f.error(), Some(s().postman_err_bad_workspace));
}
#[test]
fn a_key_that_sees_no_workspaces_says_so() {
let mut f = flow();
f.busy = Some(Phase::ListingWorkspaces);
f.apply(Msg::Workspaces(Ok(Vec::new())), &s());
assert_eq!(f.error(), Some(s().postman_err_no_workspaces));
}
#[test]
fn the_selection_follows_the_filtered_list() {
let mut f = flow();
f.workspaces = vec![ws("Alpha", "a"), ws("Billing", "b"), ws("Beta", "c")];
f.filter = "b".to_string();
assert_eq!(f.visible_workspaces().len(), 2);
f.selected = 1;
assert_eq!(f.selected_workspace().unwrap().id, "c");
assert!(f.submit_workspace());
assert_eq!(*f.step(), Step::Options);
}
#[test]
fn importing_everything_takes_every_visible_workspace() {
let mut f = flow();
f.workspaces = vec![ws("Alpha", "a"), ws("Billing", "b"), ws("Beta", "c")];
assert!(f.submit_all_workspaces());
assert_eq!(*f.step(), Step::Options);
assert_eq!(f.target_count(), 3);
assert_eq!(
f.workspace_name(),
"",
"an import of several belongs to no one workspace"
);
assert_eq!(
f.target_label(&s()),
format!("{} (3)", s().postman_all_workspaces)
);
let mut f = flow();
f.workspaces = vec![ws("Alpha", "a"), ws("Billing", "b"), ws("Beta", "c")];
f.filter = "b".to_string();
assert!(f.submit_all_workspaces());
assert_eq!(f.target_count(), 2, "only what the filter is showing");
}
#[test]
fn importing_everything_with_an_empty_list_does_nothing() {
let mut f = flow();
assert!(!f.submit_all_workspaces());
assert_eq!(*f.step(), Step::Connect);
}
#[test]
fn importing_nothing_at_all_is_refused() {
let mut f = flow();
f.chosen = vec![ws("Billing", "b")];
f.include_collections = false;
f.include_environments = false;
assert!(!f.submit_options(&s()));
assert_eq!(f.error(), Some(s().postman_err_nothing_selected));
}
#[test]
fn a_missing_destination_is_refused() {
let mut f = flow();
f.chosen = vec![ws("Billing", "b")];
f.dest = " ".to_string();
assert!(!f.submit_options(&s()));
assert_eq!(f.error(), Some(s().postman_err_dest_required));
}
#[test]
fn the_plan_is_shown_before_anything_is_downloaded() {
let mut f = flow();
f.chosen = vec![ws("Billing", "b")];
f.step = Step::Confirm;
f.busy = Some(Phase::Planning);
f.apply(Msg::Planned(Box::new(plan_with(3, 2))), &s());
assert_eq!(*f.step(), Step::Confirm);
assert!(!f.is_busy(), "the worker is parked, not working");
assert_eq!(f.plan().unwrap().item_count(), 5);
assert_eq!(
plan_summary(f.plan().unwrap(), &s()),
"3 collections · 2 environments"
);
}
#[test]
fn the_workspaces_real_name_replaces_a_typed_id() {
let mut f = flow();
f.chosen = vec![WorkspaceSummary {
id: "11111111-2222-3333-4444-555555555555".into(),
name: "11111111-2222-3333-4444-555555555555".into(),
kind: WorkspaceKind::Other(String::new()),
}];
f.apply(Msg::Planned(Box::new(plan_with(1, 0))), &s());
assert_eq!(f.workspace_name(), "Billing");
}
#[test]
fn confirming_without_a_plan_does_nothing() {
let mut f = flow();
assert!(!f.confirm());
assert_ne!(*f.step(), Step::Downloading);
}
#[test]
fn progress_counts_finished_items_not_started_ones() {
let mut f = flow();
let (tx, rx) = mpsc::channel();
f.progress_rx = Some(rx);
tx.send(ImportMsg::Item {
index: 1,
total: 4,
kind: ItemKind::Collection,
name: "A".into(),
})
.unwrap();
f.drain_progress();
assert_eq!(f.progress().done, 0, "the first item has only just begun");
assert_eq!(f.progress().total, 4);
assert_eq!(f.progress().current, "A");
tx.send(ImportMsg::Item {
index: 4,
total: 4,
kind: ItemKind::Environment,
name: "D".into(),
})
.unwrap();
f.drain_progress();
assert_eq!(f.progress().done, 3);
}
#[test]
fn a_deliberate_wait_is_reported_rather_than_looking_hung() {
let mut f = flow();
let (tx, rx) = mpsc::channel();
f.progress_rx = Some(rx);
tx.send(ImportMsg::Waiting {
reason: WaitReason::RateLimited,
secs: 12,
})
.unwrap();
f.drain_progress();
assert_eq!(f.progress().waiting, Some((WaitReason::RateLimited, 12)));
tx.send(ImportMsg::Item {
index: 2,
total: 4,
kind: ItemKind::Collection,
name: "B".into(),
})
.unwrap();
f.drain_progress();
assert_eq!(f.progress().waiting, None);
}
#[test]
fn the_busy_line_says_why_it_is_waiting_not_just_what_it_is_doing() {
let s = s();
let mut f = flow();
f.set_busy(Phase::Planning);
assert_eq!(f.busy_line(&s).as_deref(), Some(Phase::Planning.label(&s)));
let (tx, rx) = mpsc::channel();
f.progress_rx = Some(rx);
tx.send(ImportMsg::Waiting {
reason: WaitReason::RateLimited,
secs: 12,
})
.unwrap();
f.drain_progress();
let line = f.busy_line(&s).expect("still busy");
assert!(
line.contains(s.postman_waiting_limited),
"the reason for the wait belongs on the line: {line}"
);
}
#[test]
fn a_long_provider_approval_does_not_count_against_the_import() {
let s = s();
let mut f = flow();
f.set_busy(Phase::ListingWorkspaces);
let long_ago = Instant::now() - Duration::from_secs(120);
f.busy_since = Some(long_ago);
f.progress.started = Some(long_ago);
assert!(
f.busy_line(&s).unwrap().len() > Phase::ListingWorkspaces.label(&s).len(),
"two minutes in, the counter is on the line"
);
*f.key_ready.lock().unwrap() = Some(Instant::now());
f.absorb_approval_wait();
assert_eq!(
f.busy_line(&s).as_deref(),
Some(Phase::ListingWorkspaces.label(&s)),
"the elapsed counter starts again from the approval"
);
assert!(
f.progress.started.unwrap().elapsed() < Duration::from_secs(3),
"and so does the clock the ETA is extrapolated from"
);
let restarted = f.busy_since;
f.absorb_approval_wait();
assert_eq!(f.busy_since, restarted);
}
#[test]
fn the_allowance_postman_reports_is_shown_not_just_used_for_pacing() {
let s = s();
let mut f = flow();
assert_eq!(f.budget_line(&s), None, "nothing to report before a call");
let (tx, rx) = mpsc::channel();
f.progress_rx = Some(rx);
tx.send(ImportMsg::Budget {
bucket: RateBucket::Strict,
remaining: Some(8),
reset_secs: Some(45),
remaining_month: Some(812),
interval_secs: 12,
})
.unwrap();
f.drain_progress();
let line = f.budget_line(&s).expect("a budget was reported");
assert!(line.contains('8') && line.contains(s.postman_budget_window));
assert!(line.contains("812") && line.contains(s.postman_budget_month));
assert!(
line.contains(s.postman_budget_pace),
"the spacing explains the wait: {line}"
);
}
#[test]
fn the_two_rate_limits_do_not_take_turns_in_the_same_line() {
let s = s();
let mut f = flow();
let (tx, rx) = mpsc::channel();
f.progress_rx = Some(rx);
let strict = ImportMsg::Budget {
bucket: RateBucket::Strict,
remaining: Some(9),
reset_secs: Some(10),
remaining_month: Some(99_261),
interval_secs: 1,
};
let general = ImportMsg::Budget {
bucket: RateBucket::General,
remaining: Some(283),
reset_secs: Some(16),
remaining_month: Some(99_258),
interval_secs: 0,
};
tx.send(strict.clone()).unwrap();
f.drain_progress();
let first = f.budget_line(&s).expect("a budget was reported");
tx.send(general.clone()).unwrap();
f.drain_progress();
let second = f.budget_line(&s).expect("still reporting");
assert_eq!(
first.contains('9'),
second.contains('9'),
"the binding limit is the one that gets reported: {first} / {second}"
);
assert!(
!second.contains("283"),
"the roomier bucket is not what is holding the import up: {second}"
);
assert!(
second.contains("99258"),
"the monthly count is the freshest one: {second}"
);
for b in &mut f.budgets {
if b.bucket == RateBucket::Strict {
b.seen = Instant::now() - Duration::from_secs(30);
}
}
let later = f.budget_line(&s).expect("the general bucket still has one");
assert!(
later.contains("283"),
"a bucket the import has finished with stops speaking for it: {later}"
);
}
#[test]
fn an_item_that_could_not_be_fetched_is_collected_not_fatal() {
let mut f = flow();
let (tx, rx) = mpsc::channel();
f.progress_rx = Some(rx);
tx.send(ImportMsg::ItemFailed {
name: "Broken".into(),
error: "404".into(),
})
.unwrap();
f.drain_progress();
assert_eq!(f.failures().len(), 1);
assert_ne!(*f.step(), Step::Failed("404".into()));
}
#[test]
fn finishing_reports_the_summary_and_lands_on_done() {
let mut f = flow();
f.step = Step::Downloading;
f.busy = Some(Phase::Downloading);
let summary = ImportSummary {
dest: PathBuf::from("/tmp/x"),
workspace_name: "Billing".into(),
collections: 3,
environments: 2,
failures: Vec::new(),
converted_with_notes: false,
elapsed: Duration::from_secs(4),
};
let event = f.apply(Msg::Finished(Box::new(summary)), &s());
assert!(matches!(event, Some(PostmanEvent::Imported(_))));
assert_eq!(*f.step(), Step::Done);
assert!(!f.is_busy());
}
#[test]
fn going_back_to_the_key_discards_the_listing() {
let mut f = flow();
f.workspaces = vec![ws("Alpha", "a")];
f.chosen = vec![ws("Alpha", "a")];
f.plan = Some(plan_with(1, 1));
f.back_to_connect();
assert_eq!(*f.step(), Step::Connect);
assert!(f.workspaces().is_empty());
assert!(f.plan().is_none());
}
#[test]
fn the_eta_extrapolates_from_the_measured_rate() {
let p = Progress {
done: 2,
total: 10,
started: Some(Instant::now() - Duration::from_secs(4)),
..Progress::default()
};
let eta = p.eta().expect("two of ten done is enough to extrapolate");
assert!(
(14..=18).contains(&eta.as_secs()),
"unexpected eta: {}s",
eta.as_secs()
);
assert!((p.fraction() - 0.2).abs() < 0.001);
}
#[test]
fn dismissing_a_failed_listing_goes_back_to_the_key() {
let mut f = PostmanFlow::new();
f.key = "PMAK-wrong".to_string();
f.step = Step::PickWorkspace;
f.fail("401 Unauthorized".to_string());
f.clear_error(Step::PickWorkspace);
assert_eq!(*f.step(), Step::Connect);
assert_eq!(f.key, "PMAK-wrong", "the key is kept, to be corrected");
}
#[test]
fn dismissing_an_error_keeps_a_workspace_list_that_was_fetched() {
let mut f = PostmanFlow::new();
f.workspaces = vec![ws("Alpha", "a")];
f.step = Step::PickWorkspace;
f.fail("something else".to_string());
f.clear_error(Step::PickWorkspace);
assert_eq!(*f.step(), Step::PickWorkspace);
}
#[test]
fn a_failed_download_goes_back_to_the_options() {
let mut f = PostmanFlow::new();
f.chosen = vec![ws("Alpha", "a")];
f.plan = Some(plan_with(1, 1));
f.step = Step::Downloading;
f.fail("connection reset".to_string());
f.clear_error(Step::Downloading);
assert_eq!(*f.step(), Step::Options);
}
#[test]
fn the_eta_extrapolates_each_kind_from_its_own_rate() {
let p = Progress {
done: 3,
total: 202,
totals: [2, 200],
spent: [Duration::from_secs(8), Duration::from_millis(500)],
samples: [2, 1],
started: Some(Instant::now() - Duration::from_secs(9)),
..Progress::default()
};
let eta = p.eta().expect("three done is enough to extrapolate");
assert!(
(90..=110).contains(&eta.as_secs()),
"unexpected eta: {}s",
eta.as_secs()
);
}
#[test]
fn there_is_no_eta_before_the_first_item_or_after_the_last() {
let base = Progress {
total: 10,
started: Some(Instant::now()),
..Progress::default()
};
assert_eq!(
Progress {
done: 0,
..base.clone()
}
.eta(),
None
);
assert_eq!(Progress { done: 10, ..base }.eta(), None);
}
#[test]
fn the_default_folder_name_is_derived_from_the_workspace() {
assert_eq!(default_dest_name("Billing API"), "Billing API");
assert_eq!(default_dest_name("Team/Billing"), "Team-Billing");
assert_eq!(default_dest_name(" "), "Postman");
}
#[test]
fn durations_are_rounded_to_something_worth_reading() {
let s = s();
assert_eq!(human_duration(Duration::from_millis(200), &s), "1 seconds");
assert_eq!(human_duration(Duration::from_secs(45), &s), "45 seconds");
assert_eq!(human_duration(Duration::from_secs(61), &s), "2 minutes");
}
fn item(name: &str) -> ItemSummary {
ItemSummary {
uid: format!("uid-{name}"),
id: format!("id-{name}"),
name: name.to_string(),
}
}
fn plan_of(collections: Vec<ItemSummary>) -> ImportPlan {
ImportPlan::new(
vec![WorkspacePlan {
workspace_id: "ws".into(),
workspace_name: "Workspace".into(),
collections,
environments: Vec::new(),
}],
Vec::new(),
None,
)
}
#[test]
fn a_preview_rebuilds_the_folder_tree_from_prefixed_titles() {
let entry = |title: &str, method: &str| crate::hurl::HurlEntry {
title: title.to_string(),
method: method.to_string(),
..Default::default()
};
let rows = preview_rows(&[
entry("Health", "GET"),
entry("Auth/Login", "POST"),
entry("Auth/Tokens/Refresh", "POST"),
entry("Auth/Logout", "POST"),
entry("Users/List", "GET"),
]);
let shape: Vec<(usize, &str, Option<&str>)> = rows
.iter()
.map(|r| (r.depth, r.label.as_str(), r.method.as_deref()))
.collect();
assert_eq!(
shape,
vec![
(0, "Health", Some("GET")),
(0, "Auth", None),
(1, "Login", Some("POST")),
(1, "Tokens", None),
(2, "Refresh", Some("POST")),
(1, "Logout", Some("POST")),
(0, "Users", None),
(1, "List", Some("GET")),
]
);
}
#[test]
fn a_preview_counts_requests_and_repeats_what_would_not_convert() {
let json = r#"{
"info": {"name": "Sample", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"},
"item": [
{"name": "Ping", "request": {"method": "GET", "url": "https://example.net/ping"}},
{"name": "Folder", "item": [
{"name": "Made up", "request": {"method": "GET", "url": "https://example.net/{{$guid}}"}}
]}
]
}"#;
let preview = Preview::build("uid-1".into(), "Sample".into(), json);
assert_eq!(preview.requests, 2);
assert_eq!(preview.uid, "uid-1");
assert!(
preview
.rows
.iter()
.any(|r| r.label == "Folder" && r.method.is_none()),
"the folder must be a folder, not a request: {:?}",
preview.rows
);
assert!(
preview.notes.iter().any(|n| n.contains("Made up")),
"the dynamic variable must be reported against its request: {:?}",
preview.notes
);
}
#[test]
fn a_collection_already_read_reopens_without_another_call() {
let s = s();
let mut flow = PostmanFlow::new();
flow.key = "PMAK-stub".into();
flow.seed_plan(plan_of(vec![item("a"), item("b")]));
flow.seed_preview_cache(Preview {
uid: "uid-a".into(),
name: "a".into(),
rows: Vec::new(),
requests: 3,
notes: Vec::new(),
});
assert!(flow.preview_is_cached("uid-a"));
flow.preview_collection(0, &s);
assert_eq!(flow.preview().map(|p| p.requests), Some(3));
assert!(
flow.preview_pending().is_none(),
"nothing may be fetched for a collection already read"
);
}
#[test]
fn closing_a_preview_keeps_it_cached() {
let s = s();
let mut flow = PostmanFlow::new();
flow.seed_plan(plan_of(vec![item("a")]));
flow.seed_preview_cache(Preview {
uid: "uid-a".into(),
name: "a".into(),
requests: 1,
..Preview::default()
});
flow.preview_collection(0, &s);
assert!(flow.close_preview(), "there was one open");
assert!(!flow.close_preview(), "and now there is not");
assert!(flow.preview().is_none());
assert!(flow.preview_is_cached("uid-a"), "still remembered");
}
#[test]
fn replanning_forgets_the_open_preview_but_not_what_was_read() {
let s = s();
let mut flow = PostmanFlow::new();
flow.seed_plan(plan_of(vec![item("a")]));
flow.seed_preview_cache(Preview {
uid: "uid-a".into(),
name: "a".into(),
..Preview::default()
});
flow.preview_collection(0, &s);
assert!(flow.preview().is_some());
flow.to_pick_workspace();
assert!(flow.preview().is_none(), "the open one is put away");
assert_eq!(flow.preview_sel, 0);
assert!(flow.preview_is_cached("uid-a"), "but not re-fetched later");
}
#[test]
fn the_preview_highlight_stops_at_the_ends() {
let mut flow = PostmanFlow::new();
flow.seed_plan(plan_of(vec![item("a"), item("b"), item("c")]));
flow.move_preview_sel(-1);
assert_eq!(flow.preview_sel, 0);
flow.move_preview_sel(2);
assert_eq!(flow.preview_sel, 2);
flow.move_preview_sel(5);
assert_eq!(flow.preview_sel, 2);
}
#[test]
fn the_preview_highlight_copes_with_nothing_to_show() {
let mut flow = PostmanFlow::new();
assert!(flow.previewable().is_empty());
flow.move_preview_sel(1);
assert_eq!(flow.preview_sel, 0);
flow.preview_collection(0, &s());
assert!(flow.preview().is_none());
assert!(flow.preview_pending().is_none());
}
}
#[cfg(test)]
mod end_to_end {
use super::*;
use crate::i18n::Language;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
fn stub_api() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
if reader.read_line(&mut line).is_err() {
continue;
}
let path = line.split_whitespace().nth(1).unwrap_or("/").to_string();
loop {
let mut h = String::new();
match reader.read_line(&mut h) {
Ok(0) => break,
Ok(_) if h.trim().is_empty() => break,
Ok(_) => {}
Err(_) => break,
}
}
let body = if path.starts_with("/workspaces/") {
r#"{"workspace":{"id":"ws-a","name":"Alpha","type":"team"}}"#.to_string()
} else if path.starts_with("/workspaces") {
r#"{"workspaces":[{"id":"ws-a","name":"Alpha","type":"team"}]}"#.to_string()
} else if path.starts_with("/collections/") {
r#"{"collection":{"info":{"name":"Billing","schema":"v2.1.0"},
"item":[{"name":"Get","request":{"method":"GET","url":{"raw":"https://x.test/a"}}}]}}"#
.to_string()
} else if path.starts_with("/collections") {
r#"{"collections":[{"id":"c1","uid":"u-c1","name":"Billing"}],"meta":{"total":1}}"#
.to_string()
} else if path.starts_with("/environments/") {
r#"{"environment":{"id":"e1","name":"Staging",
"values":[{"key":"HOST","value":"https://s.test","enabled":true}]}}"#
.to_string()
} else if path.starts_with("/environments") {
r#"{"environments":[{"id":"e1","uid":"u-e1","name":"Staging"}]}"#.to_string()
} else {
r#"{"error":{"message":"no"}}"#.to_string()
};
let mut sock = stream;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes());
let _ = sock.flush();
}
});
format!("http://127.0.0.1:{port}")
}
fn pump(
flow: &mut PostmanFlow,
s: &Strings,
done: impl Fn(&PostmanFlow) -> bool,
) -> Option<PostmanEvent> {
for _ in 0..1200 {
let event = flow.poll(s);
if event.is_some() {
return event;
}
if done(flow) {
return None;
}
std::thread::sleep(Duration::from_millis(25));
}
panic!("the flow never got there; step = {:?}", flow.step());
}
#[test]
fn a_whole_workspace_imports_end_to_end_and_converts_to_hurl() {
let s = Strings::for_language(&Language::English);
let base = stub_api();
let dest = std::env::temp_dir().join(format!("pb_flow_e2e_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dest);
let mut flow = PostmanFlow::new();
flow.key = "PMAK-stub".to_string();
flow.base_url = base;
flow.dest = dest.to_string_lossy().into_owned();
flow.format = ImportFormat::Hurl;
flow.submit_connect(&s);
pump(&mut flow, &s, |f| !f.is_busy());
assert_eq!(flow.workspaces().len(), 1, "the listing arrived");
assert!(flow.submit_workspace());
assert!(flow.submit_options(&s));
pump(&mut flow, &s, |f| f.plan().is_some());
let plan = flow.plan().expect("a plan").clone();
assert_eq!(plan.item_count(), 2, "one collection and one environment");
assert_eq!(
plan.workspace_name(),
"Alpha",
"the plan carries the workspace's real name"
);
assert!(!dest.exists(), "planning must not touch the disk");
assert!(flow.confirm());
let event = pump(&mut flow, &s, |_| false).expect("the import finished");
let PostmanEvent::Imported(summary) = event;
assert_eq!(summary.collections, 1);
assert_eq!(summary.environments, 1);
assert!(summary.failures.is_empty(), "{:?}", summary.failures);
assert_eq!(flow.step(), &Step::Done);
assert!(
dest.join("Collections/Billing.hurl").exists(),
"the collection was converted to Hurl, not left as JSON"
);
assert!(dest.join("Environments/Staging.vars").exists());
assert_eq!(flow.progress().total, 2);
assert_eq!(flow.progress().done, 2);
let _ = std::fs::remove_dir_all(&dest);
}
#[test]
fn cancelling_at_the_confirmation_downloads_nothing() {
let s = Strings::for_language(&Language::English);
let base = stub_api();
let dest = std::env::temp_dir().join(format!("pb_flow_cancel_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dest);
let mut flow = PostmanFlow::new();
flow.key = "PMAK-stub".to_string();
flow.base_url = base;
flow.dest = dest.to_string_lossy().into_owned();
flow.workspace_ref = "12345678-1234-1234-1234-123456789abc".to_string();
flow.submit_connect(&s);
assert_eq!(flow.step(), &Step::Options, "the listing was skipped");
assert!(flow.submit_options(&s));
pump(&mut flow, &s, |f| f.plan().is_some());
flow.cancel();
std::thread::sleep(Duration::from_millis(400));
assert!(!dest.exists(), "cancelling must leave nothing behind");
}
}