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, 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)]
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>,
budget: Option<Budget>,
workspaces: Vec<WorkspaceSummary>,
chosen: Option<WorkspaceSummary>,
plan: Option<ImportPlan>,
progress: Progress,
failures: Vec<(String, String)>,
rx: Option<Receiver<Msg>>,
progress_rx: Option<Receiver<ImportMsg>>,
go: Option<Sender<()>>,
cancel: Arc<AtomicBool>,
resolved_key: Arc<Mutex<Option<(String, String)>>>,
}
#[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 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)>>) -> 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()));
}
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,
budget: None,
workspaces: Vec::new(),
chosen: None,
plan: None,
progress: Progress::default(),
failures: Vec::new(),
rx: None,
progress_rx: None,
go: None,
cancel: Arc::new(AtomicBool::new(false)),
resolved_key: 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
}
#[cfg(test)]
pub(crate) fn busy(&self) -> Option<Phase> {
self.busy
}
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 b = self.budget?;
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);
}
if let Some(n) = b.remaining_month {
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_some() || 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 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 {
self.chosen.as_ref().map(|w| w.name.as_str()).unwrap_or("")
}
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 = Some(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 bad_ref = s.postman_err_key_ref;
thread::spawn(move || {
let key = match resolve_key(&raw, &cache) {
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 = Some(ws);
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 Some(ws) = self.chosen.clone() else {
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 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) {
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 plan = match importer.plan(&ws.id, &ws.name, &options) {
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.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 = None;
self.plan = None;
self.step = Step::PickWorkspace;
}
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 = None;
self.plan = None;
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_none() => {
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.drain_progress();
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 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 {
remaining,
reset_secs,
remaining_month,
interval_secs,
} => {
self.budget = Some(Budget {
remaining,
reset_secs,
remaining_month,
interval_secs,
});
}
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 Some(chosen) = self.chosen.as_mut()
&& !plan.workspace_name.trim().is_empty()
{
chosen.name = plan.workspace_name.clone();
}
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 = Some(workspace);
}
pub(crate) fn seed_plan(&mut self, plan: ImportPlan) {
self.plan = Some(plan);
}
}
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 {
format!(
"{} {} · {} {}",
plan.collections.len(),
s.postman_word_collections,
plan.environments.len(),
s.postman_word_environments
)
}
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 {
remaining: Option<u64>,
reset_secs: Option<u64>,
remaining_month: Option<u64>,
interval_secs: u64,
}
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;
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);
assert_eq!(
resolve_key(" PMAK-abcdef ", &cache),
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())));
assert_eq!(
resolve_key(raw, &cache),
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(),
)));
assert_eq!(
resolve_key("PMAK-typed-instead", &cache),
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 {
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(),
remaining_month: 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.as_ref().unwrap().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_nothing_at_all_is_refused() {
let mut f = flow();
f.chosen = Some(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 = Some(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 = Some(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 = Some(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 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 {
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 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 = Some(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 = Some(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");
}
}
#[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");
}
}