From c125e2f6cfd5f5a6b43a64bbfe19d79e83b6c259 Mon Sep 17 00:00:00 2001 From: Ilya Gusev Date: Fri, 17 Jul 2026 16:51:47 +0000 Subject: [PATCH 1/4] feat(search): drop description from human output and e2e expectations The API no longer returns a description field on search results; snippet remains the text preview. Co-Authored-By: Claude Fable 5 --- src/commands/search.rs | 5 ----- tests/e2e/test_search.py | 2 +- tests/e2e/test_semantic.py | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/commands/search.rs b/src/commands/search.rs index 8ebd1d3..bfc0e44 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -288,17 +288,12 @@ pub async fn search(query: &str, mode: Option<&str>, filters: SearchFilters, hum for (i, result) in results.iter().enumerate() { let title = result["title"].as_str().unwrap_or("Untitled"); let url = result["url"].as_str().unwrap_or(""); - let description = result["description"].as_str().unwrap_or(""); - let desc_truncated: String = description.chars().take(200).collect(); let published = result["published_at"].as_str().unwrap_or(""); let acquired = result["acquired_at"].as_str().unwrap_or(""); let num = format!("{:>2}.", i + 1).dimmed(); eprintln!(" {} {}", num, title.bold()); eprintln!(" {}", url.cyan()); - if !desc_truncated.is_empty() { - eprintln!(" {}", desc_truncated.dimmed()); - } if !published.is_empty() || !acquired.is_empty() { let mut dates = Vec::new(); if !published.is_empty() { dates.push(format!("published: {}", published)); } diff --git a/tests/e2e/test_search.py b/tests/e2e/test_search.py index 5058fe3..5a57ebe 100644 --- a/tests/e2e/test_search.py +++ b/tests/e2e/test_search.py @@ -7,7 +7,7 @@ from conftest import SEARCH_QUERY, host_of, host_under, parse_ts, results_of, search_results, utc # published_at is omitted (not nulled) for pages with no known publish date -RESULT_FIELDS = ("url", "title", "description", "snippet", "acquired_at") +RESULT_FIELDS = ("url", "title", "snippet", "acquired_at") # --- 2.1 core --- diff --git a/tests/e2e/test_semantic.py b/tests/e2e/test_semantic.py index b03f4d8..1798b8b 100644 --- a/tests/e2e/test_semantic.py +++ b/tests/e2e/test_semantic.py @@ -18,7 +18,7 @@ def blob(result) -> str: - return " ".join(str(result.get(f) or "") for f in ("title", "description", "snippet")) + return " ".join(str(result.get(f) or "") for f in ("title", "snippet")) def test_gold_fact_mozart(kn): From ce93226df12f2fc2a72addd73a2126a1527a83c5 Mon Sep 17 00:00:00 2001 From: Ilya Gusev Date: Fri, 17 Jul 2026 16:57:45 +0000 Subject: [PATCH 2/4] feat(search): show snippet in human output The API's snippet now falls back to the page description server-side. Co-Authored-By: Claude Fable 5 --- src/commands/search.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/commands/search.rs b/src/commands/search.rs index bfc0e44..7b3b116 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -288,12 +288,18 @@ pub async fn search(query: &str, mode: Option<&str>, filters: SearchFilters, hum for (i, result) in results.iter().enumerate() { let title = result["title"].as_str().unwrap_or("Untitled"); let url = result["url"].as_str().unwrap_or(""); + let snippet = result["snippet"].as_str().unwrap_or(""); + let snippet_flat = snippet.split_whitespace().collect::>().join(" "); + let snippet_truncated: String = snippet_flat.chars().take(200).collect(); let published = result["published_at"].as_str().unwrap_or(""); let acquired = result["acquired_at"].as_str().unwrap_or(""); let num = format!("{:>2}.", i + 1).dimmed(); eprintln!(" {} {}", num, title.bold()); eprintln!(" {}", url.cyan()); + if !snippet_truncated.is_empty() { + eprintln!(" {}", snippet_truncated.dimmed()); + } if !published.is_empty() || !acquired.is_empty() { let mut dates = Vec::new(); if !published.is_empty() { dates.push(format!("published: {}", published)); } From b95de763d23a09bfcadd2143fd73b0a89179d2b1 Mon Sep 17 00:00:00 2001 From: Ilya Gusev Date: Fri, 17 Jul 2026 17:58:07 +0000 Subject: [PATCH 3/4] feat: never output description as a separate field Fold a legacy server's description into snippet (search) and drop it from fetch responses, so YAML and pretty output never carry the field regardless of backend version. e2e asserts absence. Co-Authored-By: Claude Fable 5 --- src/commands/search.rs | 22 ++++++++++++++++++++-- tests/e2e/test_fetch.py | 1 + tests/e2e/test_search.py | 1 + 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/commands/search.rs b/src/commands/search.rs index 7b3b116..62aaaaa 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -53,6 +53,20 @@ fn endpoint(path: &str, authenticated: bool) -> String { } } +/// Older servers still return `description`; fold it into `snippet` when no +/// snippet exists and drop the field — the CLI never outputs `description`. +fn fold_description_into_snippet(data: &mut Value) { + let Some(results) = data["results"].as_array_mut() else { return }; + for result in results { + let Some(obj) = result.as_object_mut() else { continue }; + let Some(desc) = obj.remove("description") else { continue }; + let has_snippet = obj.get("snippet").and_then(Value::as_str).is_some_and(|s| !s.is_empty()); + if !has_snippet && desc.as_str().is_some_and(|d| !d.is_empty()) { + obj.insert("snippet".into(), desc); + } + } +} + fn print_yaml(data: &Value) { match serde_yaml::to_string(data) { Ok(yaml) => print!("{}", yaml), @@ -276,7 +290,8 @@ pub async fn search(query: &str, mode: Option<&str>, filters: SearchFilters, hum let api_key = key_override(api_key); let api_key = api_key.as_deref(); match execute(&req, api_key).await { - Ok(data) => { + Ok(mut data) => { + fold_description_into_snippet(&mut data); if human { ui::header(&format!("keenable search \"{}\"", query)); if let Some(results) = data["results"].as_array() { @@ -330,7 +345,10 @@ pub async fn fetch(url: &str, human: bool, api_key: Option<&str>) { let api_key = key_override(api_key); let api_key = api_key.as_deref(); match execute(&req, api_key).await { - Ok(data) => { + Ok(mut data) => { + if let Some(obj) = data.as_object_mut() { + obj.remove("description"); + } if human { ui::header("keenable fetch"); let title = data["title"].as_str().unwrap_or("Untitled"); diff --git a/tests/e2e/test_fetch.py b/tests/e2e/test_fetch.py index d743510..27cb689 100644 --- a/tests/e2e/test_fetch.py +++ b/tests/e2e/test_fetch.py @@ -8,6 +8,7 @@ def test_fetch_single_url(kn): assert data["title"] == "Example Domain" assert data["url"].startswith("https://example.com") assert "# Example Domain" in data["content"] + assert "description" not in data def test_pretty_fetch(kn): diff --git a/tests/e2e/test_search.py b/tests/e2e/test_search.py index 5a57ebe..84d5fbe 100644 --- a/tests/e2e/test_search.py +++ b/tests/e2e/test_search.py @@ -20,6 +20,7 @@ def test_basic_search(basic_search): for r in results: for field in RESULT_FIELDS: assert field in r, f"result missing {field}: {list(r)}" + assert "description" not in r def test_pretty_output(kn): From bd41549ece8fb7d0c9ffab5f283f7a84ea955257 Mon Sep 17 00:00:00 2001 From: Ilya Gusev Date: Fri, 17 Jul 2026 20:11:35 +0000 Subject: [PATCH 4/4] style: rustfmt fold_description_into_snippet Co-Authored-By: Claude Fable 5 --- src/commands/search.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/commands/search.rs b/src/commands/search.rs index ec9d1e4..4ce68d2 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -66,11 +66,20 @@ fn endpoint(path: &str, authenticated: bool) -> String { /// Older servers still return `description`; fold it into `snippet` when no /// snippet exists and drop the field — the CLI never outputs `description`. fn fold_description_into_snippet(data: &mut Value) { - let Some(results) = data["results"].as_array_mut() else { return }; + let Some(results) = data["results"].as_array_mut() else { + return; + }; for result in results { - let Some(obj) = result.as_object_mut() else { continue }; - let Some(desc) = obj.remove("description") else { continue }; - let has_snippet = obj.get("snippet").and_then(Value::as_str).is_some_and(|s| !s.is_empty()); + let Some(obj) = result.as_object_mut() else { + continue; + }; + let Some(desc) = obj.remove("description") else { + continue; + }; + let has_snippet = obj + .get("snippet") + .and_then(Value::as_str) + .is_some_and(|s| !s.is_empty()); if !has_snippet && desc.as_str().is_some_and(|d| !d.is_empty()) { obj.insert("snippet".into(), desc); }