1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
//! PaperBoy — a Rust-native API client (Postman alternative). Front-ends over
//! one core: a terminal UI (default), a headless CLI runner
//! (`-c collection.hurl [-e environment.vars]`), and — behind the `gui` Cargo
//! feature — a native graphical UI.
mod cli;
mod collection;
mod env_panel;
mod environment;
mod generators;
mod git_remote;
// The GUI pulls in eframe/winit/wgpu, which dominate build time, so it is
// opt-in (`--features gui`). Everything it needs lives under `src/gui`; the
// rest of the tree never refers to it, so the gate is this one line plus the
// `--gui` dispatch below.
#[cfg(feature = "gui")]
mod gui;
mod http;
mod hurl;
mod i18n;
mod persistence;
mod postman;
mod postman_api;
mod postman_cli;
mod postman_flow;
mod postman_import;
mod probe;
mod remote_flow;
mod report;
mod report_cli;
mod request;
mod save_flow;
mod session;
mod shared_utils;
mod theme;
mod tree;
mod tui;
mod workspace;
use clap::Parser;
/// PaperBoy — a Rust API client with a terminal UI and a headless runner.
#[derive(Parser)]
#[command(
name = "paperboy",
version,
about = "PaperBoy — a Rust-native API client (a Postman alternative).",
long_about = "PaperBoy — a Rust-native API client (a Postman alternative).\n\n\
Runs in one of four modes:\n\
\x20 TUI (default) a terminal user interface\n\
\x20 CLI (-c/--collection) run a Hurl or Postman collection headlessly, then exit\n\
\x20 Report (-r/--report) run a PaperTrail report against a collection, then exit\n\
\x20 Import (--postman-import) download a Postman workspace over the API, then exit",
after_help = "Examples:\n\
\x20 paperboy Launch the terminal UI (default)\n\
\x20 paperboy -c collection.hurl Run a collection headlessly\n\
\x20 paperboy -c collection.hurl -e environment.vars Run a collection with an environment\n\
\x20 paperboy -c collection.hurl --batch Run as one batch (preserves cookies across requests)\n\
\x20 paperboy -c collection.hurl -e environment.vars -r report.trail Run a report\n\
\x20 paperboy -r report.trail Run a report, taking its collection/environment from the report's own headers\n\
\x20 paperboy -c collection.hurl -e prod.vars -e staging.vars -r report.trail Run a baseline/comparison report\n\
\x20 paperboy -c collection.hurl -r report.trail --dry-run Preview a report without sending anything\n\
\x20 paperboy -c collection.hurl -r report.trail -o out.csv Write the report to a file (- = stdout)\n\
\x20 paperboy --postman-import List the Postman workspaces your API key can see\n\
\x20 paperboy --postman-import --postman-workspace ID -o ./API Download a whole Postman workspace\n\
\x20 paperboy --postman-import --postman-all -o ./API Download every workspace the key can see\n\n\
Environment (.vars) entries are KEY=value, where the value is a literal or a\n\
{{ ... }} provider reference resolved when the environment is loaded:\n\
\x20 Literal value USERNAME=demo\n\
\x20 Process env var BASE_URL={{ env:DEMO_BASE_URL }}\n\
\x20 1Password (op CLI) API_TOKEN={{ op://Vault/Item/field }}\n\
\x20 AWS SSM parameter DB_PASSWORD={{ ssm:/path/to/param }}\n\n\
Collections are Hurl files (.hurl) or Postman collection exports (.json);\n\
Postman JSON is imported automatically."
)]
struct Cli {
/// Run the given collection (Hurl `.hurl` or Postman `.json`) headlessly and print the results.
#[arg(short = 'c', long, value_name = "FILE")]
collection: Option<String>,
/// Environment (.vars) file supplying `{{ VAR }}` values. Repeatable: pass
/// `-e` more than once to load several environments for a report (`-r`) —
/// each is named by its file stem and becomes selectable in an `ENVS` loop
/// (e.g. `-e prod.vars -e staging.vars` satisfies
/// `FOR … IN ENVS BASELINE("prod"), COMPARISON("staging")`). The first `-e`
/// is the base variable layer. A plain collection run (`-c` only) uses just
/// the first.
#[arg(short = 'e', long, value_name = "FILE")]
env: Vec<String>,
/// Run every request as a single batch instead of streaming each result
/// as soon as it finishes. Slower to show any output, but preserves
/// Hurl's automatic cookie jar (cookies remembered from `Set-Cookie`
/// response headers) across every request in the collection — the
/// default streaming mode does not carry cookies between requests (an
/// explicit `[Cookies]` section on a request is unaffected either way).
#[arg(short = 'b', long)]
batch: bool,
/// Run a PaperTrail report (`.trail`) and exit. The collection to run
/// against comes from `-c`, or (when `-c` is omitted) the report's own
/// `# collection:` header resolved relative to the report's folder. `-e`
/// supplies the base variable layer and (when repeated) the environments an
/// `ENVS` loop can name; with no `-e`, the report's `# environment:` header
/// (if any) is used instead.
#[arg(short = 'r', long, value_name = "FILE")]
report: Option<String>,
/// With `-r`: expand the report and show what it would do without sending
/// any request (no HTTP). Handy before a large run.
#[arg(long, requires = "report")]
dry_run: bool,
/// With `-r`: where to write the report output. `-` writes CSV to stdout
/// (for piping); a path's extension selects the format (`.csv`, `.json`,
/// `.html` or `.xlsx`); omitted derives the file from the report's
/// `# output:`/`# name:` headers (next to the report file, honouring the
/// `{time}` token).
#[arg(short = 'o', long, value_name = "FILE", requires = "report")]
output: Option<String>,
/// Launch the native graphical UI (eframe/egui) instead of the terminal UI.
/// Ignored in the headless modes (`-c`/`-r`). Only available when built
/// with the `gui` feature (`cargo install paperboy --locked --features gui`).
#[arg(short = 'g', long)]
gui: bool,
/// Import a Postman workspace over the Postman API and exit. With
/// `--postman-workspace` it downloads that workspace's collections and
/// environments into `-o`; without one it lists the workspaces the key can
/// see, so you can pick an id.
#[arg(long)]
postman_import: bool,
/// With `--postman-import`: the workspace to download, as its id or as the
/// address of the workspace in Postman (both are accepted, so the browser
/// address bar can simply be pasted).
#[arg(long, value_name = "ID|URL")]
postman_workspace: Option<String>,
/// With `--postman-import`: download every workspace the key can see
/// rather than one, each into its own folder inside `-o`. This is the
/// migration case; it costs two API calls per workspace to list them, so
/// it has to be asked for.
#[arg(long, conflicts_with = "postman_workspace")]
postman_all: bool,
/// With `--postman-import`: the Postman API key. Defaults to
/// `$POSTMAN_API_KEY`. Accepts the same `{{ … }}` provider references as a
/// `.vars` file (e.g. `{{ op://Private/Postman/credential }}`), so the key
/// need not appear in your shell history.
#[arg(long, value_name = "KEY")]
postman_key: Option<String>,
/// With `--postman-import`: what to download — `all` (default),
/// `collections` or `environments`.
#[arg(long, value_name = "WHAT")]
postman_what: Option<String>,
/// With `--postman-import`: the API host, for tenants that are not on
/// `api.postman.com` (EU Enterprise uses `https://api.eu.postman.com`).
#[arg(long, value_name = "URL")]
postman_base_url: Option<String>,
/// With `--postman-import`: the on-disk format — `postman` (default) keeps
/// Postman's own JSON exactly as sent, `hurl` converts collections to
/// `.hurl` and environments to `.vars`. Converting is lossy (Hurl has no
/// pre-request scripts, for one), so anything dropped is listed in
/// `CONVERSION-NOTES.md` in the imported folder.
#[arg(long, value_name = "FORMAT")]
postman_format: Option<String>,
/// With `--postman-import`: replace the destination folder if it already
/// exists. Without this, a destination that exists and is not empty is
/// refused.
#[arg(long)]
overwrite: bool,
}
fn main() {
let cli = Cli::parse();
// Headless Postman import (`--postman-import`): fetch a workspace over the
// Postman API and exit. Checked before `-c`/`-r` because it produces the
// collections those modes run, rather than running anything itself.
if cli.postman_import {
std::process::exit(postman_cli::run(postman_cli::Args {
key: cli.postman_key,
workspace: cli.postman_workspace,
all: cli.postman_all,
out: cli.output,
what: cli.postman_what,
base_url: cli.postman_base_url,
format: cli.postman_format,
overwrite: cli.overwrite,
}));
}
// Headless report mode (`-r`): run a PaperTrail report. `-c` may be omitted
// — the report's `# collection:` header (resolved relative to the report's
// folder) is used instead; `report_cli::run` raises a clear error if neither
// is available.
if let Some(report) = cli.report {
std::process::exit(report_cli::run(
cli.collection,
cli.env,
report,
cli.output,
cli.dry_run,
));
}
// Headless CLI mode (explicit "run and exit").
if let Some(collection) = cli.collection {
if cli.env.len() > 1 {
eprintln!(
"warning: multiple -e environments are only used by reports (-r); running the collection with the first one"
);
}
std::process::exit(cli::run(collection, cli.env.into_iter().next(), cli.batch));
}
// Native GUI mode (`-g/--gui`): a graphical front-end over the same core.
if cli.gui {
std::process::exit(run_gui());
}
// Terminal UI (the default).
if let Err(e) = tui::run() {
eprintln!("tui error: {e}");
std::process::exit(1);
}
std::process::exit(0);
}
/// Launch the GUI, or explain why this build can't.
///
/// The flag is always accepted so that a user who copies a `--gui` command from
/// the README gets told how to get it, rather than an unhelpful "unexpected
/// argument" from the argument parser.
#[cfg(feature = "gui")]
fn run_gui() -> i32 {
if let Err(e) = gui::run() {
eprintln!("gui error: {e}");
return 1;
}
0
}
#[cfg(not(feature = "gui"))]
fn run_gui() -> i32 {
eprintln!(
"This build of PaperBoy has no GUI. Reinstall it with the `gui` feature:\n\
\x20 cargo install paperboy --locked --features gui"
);
1
}