Skip to content

Commit c4377d1

Browse files
committed
Address remaining PR review comments, fix CI test failure, gate release to main tags
- params: change FromStr::Err from String to CliError so ? propagation works - main: simplify param parse error handling now that Err is already CliError - main: lock stdout once before jq output loop instead of per-iteration - main: align --max-pages help text to match actual behavior - README: align --max-pages description with CLI help - login: fall back to stdin when no TTY (rpassword requires /dev/tty); fixes test_login_flow failure in CI (error 6: no such device or address) - e2e: update test_login_flow ignore reason to reflect actual requirement - integration: simplify test_param_parse_error_produces_usage_error now that parse error is already a CliError - release.yml: remove pull_request trigger — release only fires on version tags - release.yml: simplify plan job outputs (no more PR-vs-tag ternary logic) - release.yml: add main-branch guard that rejects tags not on main
1 parent 1b57721 commit c4377d1

File tree

7 files changed

+55
-44
lines changed

7 files changed

+55
-44
lines changed

.github/workflows/release.yml

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ permissions:
3939
# If there's a prerelease-style suffix to the version, then the release(s)
4040
# will be marked as a prerelease.
4141
on:
42-
pull_request:
4342
push:
4443
tags:
4544
- '**[0-9]+.[0-9]+.[0-9]+*'
@@ -50,16 +49,23 @@ jobs:
5049
runs-on: "ubuntu-22.04"
5150
outputs:
5251
val: ${{ steps.plan.outputs.manifest }}
53-
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
54-
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
55-
publishing: ${{ !github.event.pull_request }}
52+
tag: ${{ github.ref_name }}
53+
tag-flag: ${{ format('--tag={0}', github.ref_name) }}
54+
publishing: 'true'
5655
env:
5756
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5857
steps:
5958
- uses: actions/checkout@v6
6059
with:
61-
persist-credentials: false
60+
persist-credentials: true
6261
submodules: recursive
62+
- name: Verify tag is on main branch
63+
run: |
64+
git fetch origin main --depth=50
65+
if ! git merge-base --is-ancestor "${{ github.sha }}" origin/main; then
66+
echo "::error::Release tags must be pushed from the main branch. Aborting."
67+
exit 1
68+
fi
6369
- name: Install dist
6470
# we specify bash to get pipefail; it guards against the `curl` command
6571
# failing. otherwise `sh` won't catch that `curl` returned non-0
@@ -70,14 +76,9 @@ jobs:
7076
with:
7177
name: cargo-dist-cache
7278
path: ~/.cargo/bin/dist
73-
# sure would be cool if github gave us proper conditionals...
74-
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
75-
# functionality based on whether this is a pull_request, and whether it's from a fork.
76-
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
77-
# but also really annoying to build CI around when it needs secrets to work right.)
7879
- id: plan
7980
run: |
80-
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
81+
dist host --steps=create --tag=${{ github.ref_name }} --output-format=json > plan-dist-manifest.json
8182
echo "dist ran successfully"
8283
cat plan-dist-manifest.json
8384
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ serpapi login
118118
- `--jq <expr>` — Client-side jq filter applied to JSON output (same as `gh --jq`)
119119
- `--api-key <key>` — Override API key (takes priority over environment and config file)
120120
- `--all-pages` — Fetch all result pages and merge array fields across pages
121-
- `--max-pages <n>`Limit fetching to the first `n` pages when paginating (can be used with or without `--all-pages`)
121+
- `--max-pages <n>`Maximum number of pages to fetch when using `--all-pages`
122122

123123
**⚠️ Important: Flag Position**
124124

src/commands/login.rs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,30 @@ use crate::commands::{check_api_error, make_client, network_err};
22
use crate::config;
33
use crate::error::CliError;
44
use std::collections::HashMap;
5+
use std::io::{self, IsTerminal, Write};
56

67
/// Prompt the user for their SerpApi API key, verify it, and persist it to the config file.
78
pub async fn run() -> Result<(), CliError> {
8-
let api_key = rpassword::prompt_password("Enter your SerpApi API key: ").map_err(|e| {
9-
CliError::UsageError {
10-
message: format!("Failed to read input: {e}"),
11-
}
12-
})?;
9+
let api_key = if io::stdin().is_terminal() {
10+
rpassword::prompt_password("Enter your SerpApi API key: ").map_err(|e| {
11+
CliError::UsageError {
12+
message: format!("Failed to read input: {e}"),
13+
}
14+
})?
15+
} else {
16+
// Non-interactive (piped/CI): print prompt to stderr and read from stdin
17+
eprint!("Enter your SerpApi API key: ");
18+
io::stderr().flush().map_err(|e| CliError::UsageError {
19+
message: format!("Failed to flush stderr: {e}"),
20+
})?;
21+
let mut line = String::new();
22+
io::stdin()
23+
.read_line(&mut line)
24+
.map_err(|e| CliError::UsageError {
25+
message: format!("Failed to read input: {e}"),
26+
})?;
27+
line
28+
};
1329
let api_key = api_key.trim();
1430

1531
if api_key.is_empty() {

src/main.rs

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ enum Command {
3636
/// Fetch all pages and merge array results
3737
#[arg(long)]
3838
all_pages: bool,
39-
/// Maximum number of pages to fetch; implies pagination (default: unlimited with --all-pages)
39+
/// Maximum number of pages to fetch when paginating with --all-pages (default: unlimited)
4040
#[arg(long)]
4141
max_pages: Option<usize>,
4242
},
@@ -75,11 +75,7 @@ async fn main() {
7575
.iter()
7676
.map(|s| s.parse::<params::Param>())
7777
.collect::<Result<Vec<_>, _>>()
78-
.unwrap_or_else(|e| {
79-
die(error::CliError::UsageError {
80-
message: e.to_string(),
81-
})
82-
});
78+
.unwrap_or_else(|e| die(e));
8379
commands::search::run(
8480
parsed_params,
8581
&api_key,
@@ -98,11 +94,7 @@ async fn main() {
9894
.iter()
9995
.map(|s| s.parse::<params::Param>())
10096
.collect::<Result<Vec<_>, _>>()
101-
.unwrap_or_else(|e| {
102-
die(error::CliError::UsageError {
103-
message: e.to_string(),
104-
})
105-
});
97+
.unwrap_or_else(|e| die(e));
10698
commands::locations::run(parsed_params).await
10799
}
108100
Command::Archive { id } => {
@@ -125,8 +117,10 @@ async fn main() {
125117
message: e.to_string(),
126118
})
127119
});
120+
let stdout = std::io::stdout();
121+
let mut out = stdout.lock();
128122
for v in &results {
129-
if let Err(e) = output::print_jq_value(v, &mut std::io::stdout()) {
123+
if let Err(e) = output::print_jq_value(v, &mut out) {
130124
die(error::CliError::ApiError {
131125
message: format!("Output error: {e}"),
132126
});

src/params.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,25 @@ pub struct Param {
88
}
99

1010
impl FromStr for Param {
11-
type Err = String;
11+
type Err = crate::error::CliError;
1212

1313
fn from_str(s: &str) -> Result<Self, Self::Err> {
1414
let mut parts = s.splitn(2, '=');
1515
match (parts.next(), parts.next()) {
16-
(Some(""), Some(_)) => Err("Empty parameter key".to_string()),
17-
(Some("api_key"), Some(_)) => Err(
18-
"Use --api-key or SERPAPI_KEY instead of passing api_key as a parameter"
16+
(Some(""), Some(_)) => Err(crate::error::CliError::UsageError {
17+
message: "Empty parameter key".to_string(),
18+
}),
19+
(Some("api_key"), Some(_)) => Err(crate::error::CliError::UsageError {
20+
message: "Use --api-key or SERPAPI_KEY instead of passing api_key as a parameter"
1921
.to_string(),
20-
),
22+
}),
2123
(Some(key), Some(value)) => Ok(Self {
2224
key: key.to_string(),
2325
value: value.to_string(),
2426
}),
25-
(Some(""), None) | (None, _) => Err("Empty parameter".to_string()),
27+
(Some(""), None) | (None, _) => Err(crate::error::CliError::UsageError {
28+
message: "Empty parameter".to_string(),
29+
}),
2630
(Some(value), None) => Ok(Self {
2731
key: "q".to_string(),
2832
value: value.to_string(),

tests/e2e.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ fn test_no_args() {
167167
}
168168

169169
#[test]
170-
#[ignore = "requires live SERPAPI_KEY env var"]
170+
#[ignore = "requires live SERPAPI_KEY env var and network access"]
171171
fn test_login_flow() {
172172
let api_key = env::var("SERPAPI_KEY").expect("SERPAPI_KEY not set");
173173
let config_dir = serpapi_cli::config::config_dir();

tests/integration_tests.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -262,17 +262,13 @@ mod error_tests {
262262

263263
#[test]
264264
fn test_param_parse_error_produces_usage_error() {
265-
// "=value" has an empty key; parsing must fail
265+
// "=value" has an empty key; parsing must fail with a CliError directly
266266
let result: Result<Param, _> = "=value".parse();
267267
assert!(result.is_err());
268-
// The error message should be wrappable into a UsageError as the fix does
269-
let err_msg = result.unwrap_err().to_string();
270-
let usage_err = CliError::UsageError {
271-
message: err_msg.clone(),
272-
};
273-
let display = format!("{}", usage_err);
268+
let err = result.unwrap_err();
269+
let display = format!("{}", err);
274270
assert!(display.contains("Usage error"));
275-
assert!(display.contains(&err_msg));
271+
assert!(display.contains("Empty parameter key"));
276272
}
277273

278274
#[test]

0 commit comments

Comments
 (0)