Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ keenable search "query" -p # Pretty output (for
keenable search "AI news" --site techcrunch.com # Restrict to site
keenable search "query" --published-after 2026-01-01 # Date filter
keenable search "query" --acquired-before 2026-05-01 # Date filter
keenable search "query" --snippet-max-length 2000 # Longer snippets (180-10000)
keenable search "query" --api-key KEY # Use a specific API key
```

Expand Down
4 changes: 4 additions & 0 deletions src/commands/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ pub async fn search(
query: &str,
mode: Option<&str>,
filters: SearchFilters,
snippet_max_length: Option<u64>,
human: bool,
api_key: Option<&str>,
) {
Expand Down Expand Up @@ -315,6 +316,9 @@ pub async fn search(
if let Some(m) = &effective_mode {
body["mode"] = json!(m);
}
if let Some(n) = snippet_max_length {
body["snippet_max_length"] = json!(n);
}
// Merge filter fields into body
if let Value::Object(filter_map) = filters.to_json()
&& let Value::Object(ref mut body_map) = body
Expand Down
18 changes: 15 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ enum Commands {

/// Search the web (outputs YAML by default, use -p for pretty output)
#[command(
after_help = "Works without login (free tier). Log in for higher rate limits.\n\nExamples:\n keenable search \"rust async\" YAML output (for agents)\n keenable search \"rust async\" -p Pretty output (for humans)\n keenable search \"AI news\" --site techcrunch.com Restrict to site\n keenable search \"dodgers braves\" --published-after 2026-01-01 Date filter (YYYY-MM-DD)\n keenable search \"AI news\" --acquired-after 7d Relative date (min, h, d, mo, y)\n keenable search \"AI news\" --acquired-after 2026-01-15T10:30:00Z ISO 8601 datetime\n keenable search \"rust async\" --api-key keen_***_***** Use a specific API key"
after_help = "Works without login (free tier). Log in for higher rate limits.\n\nExamples:\n keenable search \"rust async\" YAML output (for agents)\n keenable search \"rust async\" -p Pretty output (for humans)\n keenable search \"AI news\" --site techcrunch.com Restrict to site\n keenable search \"dodgers braves\" --published-after 2026-01-01 Date filter (YYYY-MM-DD)\n keenable search \"AI news\" --acquired-after 7d Relative date (min, h, d, mo, y)\n keenable search \"AI news\" --acquired-after 2026-01-15T10:30:00Z ISO 8601 datetime\n keenable search \"rust async\" --snippet-max-length 2000 Longer snippets (180-10000)\n keenable search \"rust async\" --api-key keen_***_***** Use a specific API key"
)]
Search {
/// Search query
Expand Down Expand Up @@ -167,6 +167,10 @@ enum Commands {
#[arg(long)]
published_before: Option<String>,

/// Maximum snippet length in characters (API accepts 180-10000)
#[arg(long = "snippet-max-length")]
snippet_max_length: Option<u64>,

/// Pretty-print output for humans instead of YAML
#[arg(short = 'p', long = "pretty")]
pretty: bool,
Expand Down Expand Up @@ -317,6 +321,7 @@ async fn main() {
acquired_before,
published_after,
published_before,
snippet_max_length,
pretty,
api_key,
} => {
Expand All @@ -327,8 +332,15 @@ async fn main() {
published_after,
published_before,
};
commands::search::search(&query, mode.as_deref(), filters, pretty, api_key.as_deref())
.await;
commands::search::search(
&query,
mode.as_deref(),
filters,
snippet_max_length,
pretty,
api_key.as_deref(),
)
.await;
}
Commands::Fetch {
url,
Expand Down
22 changes: 22 additions & 0 deletions tests/e2e/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ def test_result_count(basic_search):
print(f"\nResult count for 'rust async patterns': {count}")


def test_snippet_max_length(kn):
def avg_snippet(data):
lens = [len(r.get("snippet") or "") for r in results_of(data)]
return sum(lens) / len(lens)
Comment on lines +50 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Snippet test divide-by-zero 🐞 Bug ☼ Reliability

test_snippet_max_length computes an average as sum(lens)/len(lens) without guarding against an empty
results list, which can raise ZeroDivisionError and fail the e2e suite with an unhandled exception.
Agent Prompt
## Issue description
`test_snippet_max_length` calculates an average snippet length by dividing by `len(lens)`. If the API returns zero results (an empty `results` list), this becomes a division by zero and the test errors out (not a clean assertion failure / skip).

## Issue Context
The e2e suite already has a pattern for handling vacuous assertions when search returns no results (`_non_empty_results` uses `pytest.skip`). The new snippet-length test should follow a similar approach or at least assert non-empty results before averaging.

## Fix Focus Areas
- tests/e2e/test_search.py[50-61]

### Suggested change
Update `avg_snippet()` to either:
- `assert results_of(data), "no results returned"` before computing the average, or
- `pytest.skip(...)` when results are empty (preferred if empty results are acceptable for live API variability).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


short = kn("search", SEARCH_QUERY, "--snippet-max-length", "180").yaml()
long = kn("search", SEARCH_QUERY, "--snippet-max-length", "5000").yaml()
# Caps overshoot by ~10% (applied per-fragment upstream), so assert loose
# bounds far apart instead of exact limits. A dropped param would give
# both runs the same default length and fail one of the two.
assert avg_snippet(short) < 500
assert avg_snippet(long) > 1000


def test_snippet_max_length_out_of_range(kn):
res = kn("search", SEARCH_QUERY, "--snippet-max-length", "50")
assert res.code == 1
data = res.yaml()
assert data["error"] == "Invalid parameter"
assert "snippet_max_length" in data["message"]


# --- 2.2 modes ---

def test_mode_realtime(kn):
Expand Down
Loading